diff --git a/.build_version b/.build_version index 524cb552..88c5fb89 100644 --- a/.build_version +++ b/.build_version @@ -1 +1 @@ -1.1.1 +1.4.0 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..3d4e8f53 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @MrAlders0n diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..b6711d2a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + pull_request: + branches: [main, dev] + workflow_dispatch: + +permissions: + contents: read + +jobs: + analyze: + name: Analyze & Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + channel: 'stable' + + - run: flutter pub get + - run: dart run build_runner build --delete-conflicting-outputs + - run: dart format --output=none --set-exit-if-changed . + - run: flutter analyze + - run: flutter test + + watch-logic: + name: Watch Wire Rules + # The watch app's decision logic — snapshot ordering, cue presentation, + # staleness — decides what the wearer sees and was previously unreachable + # from CI: the analyze job runs on Linux and `flutter test` compiles no + # Swift. These are plain SwiftPM tests over Foundation-only sources, so + # they need no Xcode project, simulator or signing identity, and run in + # seconds. This does not cover WatchConnectivity, SwiftUI or MapKit; those + # still want a wrist. + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Unit-test the wire rules + run: swift test + working-directory: ios/WatchLogicTests + + # Compiles every watch source against the real watchOS SDK without an + # Xcode project, a simulator or a signing identity, so a Swift break in + # the companion app fails a PR instead of Adam's next device build. + # + # It type-checks sources; it does not link, and it knows nothing about + # target membership. A file added to the folder but never added to the + # target still passes here and still fails to build in Xcode, as do + # embed-phase, entitlement and signing mistakes. + - name: Type-check the watch target + run: > + xcrun --sdk watchos swiftc -typecheck + -target arm64_32-apple-watchos11.0 + ios/MeshMapperWatch/*.swift ios/Shared/MeshMapperWatchPayload.swift diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index e9ea065b..00000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Deploy to GitHub Pages - -on: - push: - branches: [main, dev] - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - deploy: - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment. outputs.page_url }} - steps: - - uses: actions/checkout@v4 - - - uses: subosito/flutter-action@v2 - with: - channel: 'stable' - - - name: Generate build version - id: version - run: echo "APP_VERSION=APP-$(date +%s)" >> $GITHUB_OUTPUT - - - run: flutter pub get - - run: dart run build_runner build --delete-conflicting-outputs - - run: flutter build web --release --base-href "/" --dart-define=API_KEY=${{ secrets.API_KEY }} --dart-define=APP_VERSION=${{ steps.version.outputs.APP_VERSION }} - - - uses: actions/configure-pages@v4 - - - uses: actions/upload-pages-artifact@v3 - with: - path: build/web - - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index d7c37fee..7f45a74b 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,15 @@ meshmapper-debug-*.txt # Test/debug output drive_route_map*.html +# Build metadata written by Build.sh (machine-specific) +.last_build + +# Local TestFlight "What to Test" scratch text picked up by set_whats_new.sh +WhatToTest.txt + # Claude Code .claude/ CLAUDE.md + +# SwiftPM build products for the watch wire-rule tests +ios/WatchLogicTests/.build/ diff --git a/Build.sh b/Build.sh index 202b62c0..bbf0f234 100755 --- a/Build.sh +++ b/Build.sh @@ -5,6 +5,59 @@ set -e # Exit on any error +# CLI flags for scripted use: ./Build.sh [--type dev|prod] [--version X.Y.Z] [--dry-run] +# With no flags the script behaves exactly as before (interactive prompts). +CLI_TYPE="" +CLI_VERSION="" +DRY_RUN=0 +while [ $# -gt 0 ]; do + case "$1" in + --type) CLI_TYPE="$2"; shift 2 ;; + --version) CLI_VERSION="$2"; shift 2 ;; + --dry-run) DRY_RUN=1; shift ;; + *) + echo "Error: Unknown argument: $1" + echo "Usage: ./Build.sh [--type dev|prod] [--version X.Y.Z] [--dry-run]" + exit 1 + ;; + esac +done +if [ -n "$CLI_TYPE" ] && [ "$CLI_TYPE" != "dev" ] && [ "$CLI_TYPE" != "prod" ]; then + echo "Error: --type must be 'dev' or 'prod'" + exit 1 +fi +if [ "$DRY_RUN" = "1" ] && { [ -z "$CLI_TYPE" ] || [ -z "$CLI_VERSION" ]; }; then + echo "Error: --dry-run requires --type and --version" + exit 1 +fi +# Both flags given -> never prompt; missing secrets are an error instead +NONINTERACTIVE=0 +if [ -n "$CLI_TYPE" ] && [ -n "$CLI_VERSION" ]; then + NONINTERACTIVE=1 +fi + +# maplibre_gl 0.25.0 plugin requires JDK 21 to compile. +# Force the build to use Homebrew openjdk@21, regardless of the user's shell JAVA_HOME. +JDK21_HOME="/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home" +if [ ! -d "$JDK21_HOME" ]; then + echo "Error: JDK 21 not found at $JDK21_HOME" + echo "Install with: brew install openjdk@21" + exit 1 +fi +export JAVA_HOME="$JDK21_HOME" +export PATH="$JAVA_HOME/bin:$PATH" + +# Local release secrets (optional, never committed; lives outside the repo) +RELEASE_ENV="$HOME/.meshmapper_release.env" +if [ -f "$RELEASE_ENV" ]; then + # set -a exports everything the file assigns. Gradle reads the signing + # passwords with System.getenv, so a plain "NAME=value" in the file has to + # be exported here or the release build fails on a null store password. + set -a + source "$RELEASE_ENV" + set +a +fi + # Semver comparison: returns 0 (true) if $1 >= $2 version_gte() { local IFS=. @@ -16,31 +69,59 @@ version_gte() { return 0 } -# API key - prompt if not set via environment variable -if [ -z "$MESHMAPPER_API_KEY" ]; then - echo "Enter MeshMapper API key:" - read -s MESHMAPPER_API_KEY - if [ -z "$MESHMAPPER_API_KEY" ]; then - echo "Error: API key is required." +# Validate a --version value; exits instead of re-prompting (scripted use) +require_valid_version() { + if ! [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Version must be in X.Y.Z format (e.g. 1.0.0)" exit 1 fi - echo "" -fi - -# Android signing - prompt for passwords if not set -if [ -z "$SIGNING_STORE_PASSWORD" ]; then - echo "Enter keystore password:" - read -s SIGNING_STORE_PASSWORD - export SIGNING_STORE_PASSWORD -fi + if ! version_gte "$1" "$LAST_VERSION"; then + echo "Error: Version must be >= $LAST_VERSION" + exit 1 + fi +} -if [ -z "$SIGNING_KEY_PASSWORD" ]; then - echo "Enter key password (or press Enter if same as keystore):" - read -s SIGNING_KEY_PASSWORD +if [ "$NONINTERACTIVE" = "1" ]; then + # Scripted run - secrets must already be set (env or $RELEASE_ENV), never prompt + MISSING="" + [ -z "$MESHMAPPER_API_KEY" ] && MISSING="$MISSING MESHMAPPER_API_KEY" + [ -z "$SIGNING_STORE_PASSWORD" ] && MISSING="$MISSING SIGNING_STORE_PASSWORD" + if [ -n "$MISSING" ]; then + echo "Error: Missing required secrets:$MISSING" + echo "Set them in $RELEASE_ENV or the environment." + exit 1 + fi if [ -z "$SIGNING_KEY_PASSWORD" ]; then SIGNING_KEY_PASSWORD="$SIGNING_STORE_PASSWORD" fi - export SIGNING_KEY_PASSWORD + export SIGNING_STORE_PASSWORD SIGNING_KEY_PASSWORD +else + # API key - prompt if not set via environment variable + if [ -z "$MESHMAPPER_API_KEY" ]; then + echo "Enter MeshMapper API key:" + read -s MESHMAPPER_API_KEY + if [ -z "$MESHMAPPER_API_KEY" ]; then + echo "Error: API key is required." + exit 1 + fi + echo "" + fi + + # Android signing - prompt for passwords if not set + if [ -z "$SIGNING_STORE_PASSWORD" ]; then + echo "Enter keystore password:" + read -s SIGNING_STORE_PASSWORD + export SIGNING_STORE_PASSWORD + fi + + if [ -z "$SIGNING_KEY_PASSWORD" ]; then + echo "Enter key password (or press Enter if same as keystore):" + read -s SIGNING_KEY_PASSWORD + if [ -z "$SIGNING_KEY_PASSWORD" ]; then + SIGNING_KEY_PASSWORD="$SIGNING_STORE_PASSWORD" + fi + export SIGNING_KEY_PASSWORD + fi fi # Read last version from .build_version @@ -58,64 +139,82 @@ fi # Generate single epoch for all builds (used as build-number regardless of release type) EPOCH=$(date +%s) -# Release type prompt -echo "" -echo "Release type?" -echo " 1) Dev (APP-)" -echo " 2) Production (APP-x.y.z)" -echo "" -read -p "Select [1]: " RELEASE_TYPE -RELEASE_TYPE=${RELEASE_TYPE:-1} +# Release type - from --type flag, or prompt +if [ -n "$CLI_TYPE" ]; then + if [ "$CLI_TYPE" = "prod" ]; then RELEASE_TYPE="2"; else RELEASE_TYPE="1"; fi +else + echo "" + echo "Release type?" + echo " 1) Dev (APP-)" + echo " 2) Production (APP-x.y.z)" + echo "" + read -p "Select [1]: " RELEASE_TYPE + RELEASE_TYPE=${RELEASE_TYPE:-1} +fi if [ "$RELEASE_TYPE" = "2" ]; then - # Production build - prompt for version - while true; do - read -p "Enter version [$LAST_VERSION]: " VERSION_NUMBER - VERSION_NUMBER=${VERSION_NUMBER:-$LAST_VERSION} - - # Validate semver format - if ! [[ "$VERSION_NUMBER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Error: Version must be in X.Y.Z format (e.g. 1.0.0)" - continue - fi + # Production build - version from --version flag, or prompt + if [ -n "$CLI_VERSION" ]; then + VERSION_NUMBER="$CLI_VERSION" + require_valid_version "$VERSION_NUMBER" + else + while true; do + read -p "Enter version [$LAST_VERSION]: " VERSION_NUMBER + VERSION_NUMBER=${VERSION_NUMBER:-$LAST_VERSION} - # Validate >= last version - if ! version_gte "$VERSION_NUMBER" "$LAST_VERSION"; then - echo "Error: Version must be >= $LAST_VERSION" - continue - fi + # Validate semver format + if ! [[ "$VERSION_NUMBER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Version must be in X.Y.Z format (e.g. 1.0.0)" + continue + fi - break - done + # Validate >= last version + if ! version_gte "$VERSION_NUMBER" "$LAST_VERSION"; then + echo "Error: Version must be >= $LAST_VERSION" + continue + fi + + break + done + fi - # Update .build_version - echo "$VERSION_NUMBER" > "$VERSION_FILE" + # Update .build_version (skipped on dry run) + if [ "$DRY_RUN" != "1" ]; then + echo "$VERSION_NUMBER" > "$VERSION_FILE" + fi APP_VERSION="APP-$VERSION_NUMBER" FILE_TAG="$VERSION_NUMBER" else - # Dev build - prompt for target version - while true; do - read -p "Enter target version [$LAST_VERSION]: " VERSION_NUMBER - VERSION_NUMBER=${VERSION_NUMBER:-$LAST_VERSION} - - # Validate semver format - if ! [[ "$VERSION_NUMBER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Error: Version must be in X.Y.Z format (e.g. 1.0.0)" - continue - fi + # Dev build - target version from --version flag, or prompt + if [ -n "$CLI_VERSION" ]; then + VERSION_NUMBER="$CLI_VERSION" + require_valid_version "$VERSION_NUMBER" + else + while true; do + read -p "Enter target version [$LAST_VERSION]: " VERSION_NUMBER + VERSION_NUMBER=${VERSION_NUMBER:-$LAST_VERSION} - # Validate >= last version - if ! version_gte "$VERSION_NUMBER" "$LAST_VERSION"; then - echo "Error: Version must be >= $LAST_VERSION" - continue - fi + # Validate semver format + if ! [[ "$VERSION_NUMBER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Version must be in X.Y.Z format (e.g. 1.0.0)" + continue + fi - break - done + # Validate >= last version + if ! version_gte "$VERSION_NUMBER" "$LAST_VERSION"; then + echo "Error: Version must be >= $LAST_VERSION" + continue + fi - # Update .build_version - echo "$VERSION_NUMBER" > "$VERSION_FILE" + break + done + fi + + # Update .build_version (skipped on dry run) + if [ "$DRY_RUN" != "1" ]; then + echo "$VERSION_NUMBER" > "$VERSION_FILE" + fi APP_VERSION="APP-$EPOCH" FILE_TAG="$EPOCH" @@ -125,6 +224,27 @@ fi ANDROID_DIR="$HOME/Documents/MeshMapper_Apps/Andriod" IOS_DIR="$HOME/Documents/MeshMapper_Apps/IOS" +# Dry run: everything is resolved and validated - print the plan and stop +if [ "$DRY_RUN" = "1" ]; then + echo "" + echo "============================================" + echo "DRY RUN - nothing will be built" + echo "Release type: $([ "$RELEASE_TYPE" = "2" ] && echo Production || echo Dev)" + echo "Version: $APP_VERSION" + echo "Build name: $VERSION_NUMBER" + echo "Build number: $EPOCH" + echo "Secrets: present (API key + signing passwords)" + echo "" + echo "Would produce:" + echo " APK: $ANDROID_DIR/MeshMapper-$FILE_TAG.apk" + echo " AAB: $ANDROID_DIR/MeshMapper-$FILE_TAG.aab" + echo " IPA: $IOS_DIR/MeshMapper-$FILE_TAG.ipa" + echo "" + echo ".build_version and .last_build not modified" + echo "============================================" + exit 0 +fi + echo "" echo "============================================" echo "MeshMapper Build Script" @@ -153,7 +273,8 @@ echo "" # Build iOS IPA echo "[3/3] Building iOS IPA..." -flutter build ipa --release --build-name="$VERSION_NUMBER" --build-number="$EPOCH" --dart-define="APP_VERSION=$APP_VERSION" --dart-define="API_KEY=$MESHMAPPER_API_KEY" +(cd ios && pod install) +flutter build ipa --release --build-name="$VERSION_NUMBER" --build-number="$EPOCH" --dart-define="APP_VERSION=$APP_VERSION" --dart-define="API_KEY=$MESHMAPPER_API_KEY" --export-options-plist=ios/ExportOptions.plist cp build/ios/ipa/mesh_mapper.ipa "$IOS_DIR/MeshMapper-$FILE_TAG.ipa" echo "✓ Built: MeshMapper-$FILE_TAG.ipa" echo "" @@ -168,3 +289,19 @@ echo " APK: $ANDROID_DIR/MeshMapper-$FILE_TAG.apk" echo " AAB: $ANDROID_DIR/MeshMapper-$FILE_TAG.aab" echo " IPA: $IOS_DIR/MeshMapper-$FILE_TAG.ipa" echo "============================================" + +# Record build metadata for the release flow (gitignored; only written on full success) +LAST_BUILD_FILE="$(dirname "$0")/.last_build" +{ + echo "RELEASE_TYPE=$([ "$RELEASE_TYPE" = "2" ] && echo prod || echo dev)" + echo "VERSION_NUMBER=$VERSION_NUMBER" + echo "EPOCH=$EPOCH" + echo "APP_VERSION=$APP_VERSION" + echo "FILE_TAG=$FILE_TAG" + echo "APK_PATH=$ANDROID_DIR/MeshMapper-$FILE_TAG.apk" + echo "AAB_PATH=$ANDROID_DIR/MeshMapper-$FILE_TAG.aab" + echo "IPA_PATH=$IOS_DIR/MeshMapper-$FILE_TAG.ipa" + echo "ARCHIVE_PATH=$(pwd)/build/ios/archive/Runner.xcarchive" + echo "GIT_COMMIT=$(git rev-parse HEAD 2>/dev/null || echo unknown)" + echo "BUILT_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)" +} > "$LAST_BUILD_FILE" diff --git a/CNAME b/CNAME deleted file mode 100644 index e4ba2a27..00000000 --- a/CNAME +++ /dev/null @@ -1 +0,0 @@ -wd.meshmapper.net \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c37c69b..a46f5132 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,8 @@ Without an API key, the app will function normally for UI work, BLE connectivity ### Web Development +> **Note:** The web app is no longer built or published — development focuses on Android and iOS. The web code remains in the repo, so these instructions still work if you need to run the web target locally. + For local web development with CORS issues: ```bash flutter run -d chrome --web-browser-flag="--disable-web-security" diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b7b1ad2d..83683586 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -4,7 +4,7 @@ This document provides comprehensive architecture documentation and development ## Project Overview -MeshMapper Flutter App is a cross-platform wardriving application for MeshCore mesh network devices. It's a Flutter port of the [MeshMapper WebClient](https://github.com/MeshMapper/MeshMapper_WebClient), supporting Android, iOS, and Web (Chrome/Edge only). +MeshMapper Flutter App is a cross-platform wardriving application for MeshCore mesh network devices. It's a Flutter port of the [MeshMapper WebClient](https://github.com/MeshMapper/MeshMapper_WebClient), supporting Android and iOS. The web (Chrome/Edge) target's code is retained in the codebase but the web app is no longer built or published. **Purpose**: Connect to MeshCore devices via Bluetooth Low Energy, send GPS-tagged pings to the `#wardriving` channel, track repeater echoes, and post coverage data to the MeshMapper API for community mesh mapping. @@ -33,8 +33,33 @@ flutter test # Run a single test file flutter test test/services/gps_service_test.dart + +# Watch wire rules — snapshot ordering, cue presentation, staleness. +# Plain SwiftPM over Foundation-only sources: no Xcode project, no simulator, +# no signing. macOS only. +(cd ios/WatchLogicTests && swift test) + +# Type-check every watch source against the watchOS SDK, without building +xcrun --sdk watchos swiftc -typecheck -target arm64_32-apple-watchos11.0 \ + ios/MeshMapperWatch/*.swift ios/Shared/MeshMapperWatchPayload.swift ``` +**What the watch is and is not covered by.** `WatchWireRules` in +`ios/Shared/MeshMapperWatchPayload.swift` holds the decisions that pick what the +wearer sees, deliberately kept Foundation-only and free of `WCSession` so they +can be tested at all — `WatchSessionClient` is `@Observable`, `@MainActor`, and +reaches `WCSession.default` through a computed property with no injection point. +Logic that belongs to the wire goes there; the client keeps observable state and +timers. `ios/WatchLogicTests` compiles the shipping file through a symlink, so +there is no copy to drift. + +That covers the rules and, via the type-check, a Swift compile break. It does +**not** cover WatchConnectivity delivery, SwiftUI, MapKit, or anything about +target membership: a file added to `ios/MeshMapperWatch/` but never added to the +target type-checks here and still fails to build in Xcode, as do embed-phase, +entitlement and signing mistakes. Those still need a real build, and the +delivery races still need a wrist. + ### Building for Release ```bash # Use Build.sh — prompts for API key and signing passwords @@ -42,8 +67,27 @@ flutter test test/services/gps_service_test.dart # Or set API key via environment variable to skip prompt MESHMAPPER_API_KEY= ./Build.sh + +# Non-interactive (secrets from ~/.meshmapper_release.env or env vars) +./Build.sh --type prod --version 1.3.1 # or --type dev +./Build.sh --type prod --version 1.3.1 --dry-run # print resolved plan, build nothing + +# Upload the built iOS archive to App Store Connect (needs ASC API key, see upload_ios.sh) +./upload_ios.sh + +# Set the TestFlight "What to Test" text on the uploaded build (same ASC API key). +# This cannot ride along with the upload, so it is a separate leg that waits for +# App Store Connect to register the build. +./set_whats_new.sh --notes-file notes.txt ``` +The upload export uses explicit App Store profiles from +`ios/ExportOptionsUpload.plist`. The Runner profile must include the App Group +entitlement, and the App Intents extension needs its own profile with the same +group. When either target's capabilities change, regenerate and reinstall the +named profiles before exporting; an existing profile is not automatically +updated by adding the developer-portal capability. + ### Debug Logging - Web: Add `?debug=1` to URL to enable debug logging in browser console - Mobile: Debug logging enabled in debug builds via `kDebugMode`; disabled in release builds @@ -55,11 +99,20 @@ MESHMAPPER_API_KEY= ./Build.sh The app uses a layered service architecture with clear separation of concerns: **Bluetooth Abstraction Layer** (`lib/services/bluetooth/`): -- `BluetoothService`: Abstract interface for BLE operations +- `BluetoothService`: Abstract interface for BLE operations, implements `CompanionTransport` - `MobileBluetoothService`: Android/iOS implementation using `flutter_blue_plus` - `WebBluetoothService`: Web implementation using `flutter_web_bluetooth` - Platform selection happens at runtime in `main.dart` using `kIsWeb` +**Transport Layer** (`lib/services/transport/`): +- `CompanionTransport`: Transport-agnostic interface for MeshCore companion connections (BLE, TCP, USB Serial) +- `StreamFrameCodec`: Framing codec for TCP/USB Serial (`[0x3C][len_lo][len_hi][payload]` out, `[0x3E][len_lo][len_hi][payload]` in) +- `StreamTransportBase`: Abstract base for TCP and USB Serial transports, owns codec and connection lifecycle +- `TcpService`: TCP socket transport with saved connections persistence (Android/iOS) +- `AndroidSerialService`: USB Serial via USB OTG on Android using `usb_serial` package +- `WebSerialService`: USB Serial via Web Serial API (Chrome/Edge) using `dart:js_interop` +- Platform matrix: BLE (all platforms), TCP (Android/iOS), USB Serial (Android/Web) + **MeshCore Protocol Layer** (`lib/services/meshcore/`): - `MeshCoreConnection`: Implements the 9-step connection workflow and MeshCore companion protocol - `PacketParser`: Binary packet parsing with BufferReader/Writer utilities @@ -81,11 +134,62 @@ The app uses a layered service architecture with clear separation of concerns: - `AppStateProvider`: Single ChangeNotifier for all app state using Provider pattern - All UI updates happen via `notifyListeners()` after state mutations +### Map Rebuild Isolation + +The MapLibre `MapWidget` is by far the most expensive subtree. It is therefore +**not** subscribed to the whole provider — that previously made it rebuild on +every `notifyListeners()` (including noise-floor/battery/stats every few seconds +and the dense-mesh passive-RX pin storm at 10–20×/sec), which pinned the CPU/GPU +and overheated the device during wardriving. + +Instead the map is isolated: +- `AppStateProvider` exposes `mapRevision`, an integer bumped only when + **map-rendered** state changes (TX/RX/disc/trace markers, echoes, zone + repeater load, history view, marker/log clears, marker-style prefs). +- Two helpers drive it: `_notifyMapNow()` (bump + immediate notify, for + low-frequency changes) and `_notifyMapThrottled()` (bump + ~250 ms + leading+trailing coalescing, for the high-frequency RX/echo storm — caps map + rebuilds at ~4/sec while pin data updates immediately). +- `MapWidget` is wrapped in a `Selector` (`home_screen.dart` `_buildMapSelector`) + keyed on `(mapRevision, focus, history, padding, controls)` and uses + `context.read` internally, so it is cached across all UI-only notifies. +- UI-only state (noise floor, battery, live stats) calls plain + `notifyListeners()` and leaves `mapRevision` untouched, so the status bar + updates without rebuilding the map. + +**GPS position does NOT bump `mapRevision`.** Position updates ~1–2×/sec while +driving; rebuilding the map that often relayouts the iOS platform view (~24 ms +each) — a dominant heat source. Instead, the GPS listener calls plain +`notifyListeners()`, and `MapWidget` drives camera-follow, derived heading, and +the GPS puck from a **direct provider listener** (`_onPositionNotify` → +`_handleGpsPosition`) that calls the native controller (`animateCamera` / +`updateSymbol`) every tick — real-time nav, no widget rebuild. The GPS-info +overlay rebuilds only when the map itself does. + +**Coverage overlay opacity does NOT bump `mapRevision`.** It is UI-only state, +so bumping the revision would relayout the platform view once per slider step. +`MapWidget` applies it through a **direct provider listener** +(`_onCoverageOpacityNotify`) that pushes the value into the live fill layers via +`setLayerProperties`. A `build()` watcher cannot serve this: the map is behind +the `mapRevision` Selector and never rebuilds on an opacity change, so the value +only reached MapLibre on the next full overlay rebuild. + +**The Selector MUST be memoized (identity-stable).** `HomeScreen.build()` uses +`context.watch`, so it rebuilds on every notify (incl. the 2 Hz GPS one). +provider's `Selector` invalidates its cache whenever `oldWidget != widget` +(`selector.dart:77`), so a fresh inline `Selector(...)` instance each build +forces `MapWidget` to rebuild **before** the value comparison ever runs — +silently defeating the isolation. `_buildMapSelector` therefore caches the +`Selector` instance, keyed only on the State fields its closures capture +(`isLandscape` / `_isControlsMinimized` / `_mapControlsExpanded`), so its +identity survives parent rebuilds and the value comparison actually gates the +map. + ### 9-Step Connection Workflow Critical safety: The connection sequence MUST complete in order. -1. **BLE GATT Connect**: Platform-specific BLE connection +1. **Transport Connect**: Platform-specific transport connection (BLE GATT, TCP socket, or USB Serial port) 2. **Protocol Handshake**: `deviceQuery()` with protocol version 3. **Device Info**: `deviceQuery()` returns manufacturer string, then `getSelfInfo()` acquires device public key (required for geo-auth API authentication). If `getSelfInfo()` fails, the entire connection fails. 4. **Device Identification**: Parse manufacturer string, match against `device-models.json` (does NOT modify radio settings) @@ -256,7 +360,7 @@ Sound notifications for TX pings and RX observations, configurable on/off. Prevents session timeout during long wardriving sessions by periodically refreshing the session expiry. - **Trigger**: Enabled when auto-ping mode starts (`enableHeartbeat()`), disabled on disconnect or leaving auto mode -- **Timing**: Heartbeat fires **1 minute before** session `expires_at`. If already expired, sends immediately. +- **Timing**: Heartbeat fires **1 minute before** session `expires_at`. If already expired, sends immediately, but never more than one send per 30s (`minHeartbeatSpacing`). The floor matters because `expires_at` is server-clock while the delay math runs on the device clock: a device clock 4+ minutes fast (server TTL is 300s) makes every fresh expiry read as already due, and without the floor the "send immediately" path re-fired one POST per network round trip (the 2026-08-29 storm: 361k POSTs in 64 minutes from one device). An in-flight guard keeps re-entrant `scheduleHeartbeat` callers (upload success, per-ping session check) from stacking concurrent send chains, and a circuit breaker (`maxHeartbeatsPerMinute` = 6) pauses the lane for 60s as a backstop. Regression tests: `test/services/api_service_heartbeat_test.dart`. - **Mechanism**: POST to `/wardrive-api.php/wardrive` with `heartbeat: true` flag and optional GPS coordinates - **Response**: Returns updated `expires_at`, which schedules the next heartbeat - **Flow**: Auth response sets initial `expires_at` → each wardrive POST or heartbeat updates it → timer reschedules automatically @@ -281,7 +385,568 @@ Keeps the screen on during auto-ping to prevent device sleep during wardriving s - **Platform**: Android and iOS only (Web N/A — always requires active tab) - **File**: `lib/services/wakelock_service.dart` -## Critical Protocol Details +### MyMeshMapper Account + Companion Linking + +Signs the user in to their portal account and offers to bind each connected +radio's Ed25519 pubkey to it, so their wardriving counts toward that account. +**Linking is strictly non-fatal — no failure may surface as an error or affect +a connection.** Mobile only (`!kIsWeb`). Server contract: +`MeshMapper_Server/docs/SPEC-app-portal-link.md`. + +- **Sign-in**: system-browser PKCE (S256 only). `PkcePair` (`lib/utils/pkce.dart`) + mints a 43-char verifier + challenge + independent `state`; the app opens + `portal.php?app_authorize=1&…` with `LaunchMode.externalApplication` (an in-app + WebView would see the user's password) and the portal deep-links back + `meshmapper-auth://callback?code=…&state=…`. The exchange answers with the + identity but NOT the linked pubkeys, so it is followed by one `me` call: + without it the Settings account card reads "0 device(s) on this account" + until the next radio connect happens to refresh it. That call runs AFTER + `onSignInComplete`, so a device list that fails never colours the sign-in. +- **Scheme**: `meshmapper-auth` (host `callback`), registered in + `ios/Runner/Info.plist` `CFBundleURLTypes` and the `MainActivity` + VIEW/BROWSABLE intent-filter. Deliberately NOT the bare `meshmapper` scheme — + that is a paste-only clipboard format (`docs/CUSTOM_API_ENDPOINT.md`, + `meshmapper://custom-api?…`) and registering it would hijack those links. +- **Token**: 64-hex bearer in `flutter_secure_storage` + (`SecureTokenStore`, keys `portal_app_token` / `portal_pending_pkce`; + iOS `first_unlock_this_device`, Android EncryptedSharedPreferences in + `MeshMapperSecure`). Reads NEVER throw — a restored Android backup carries + ciphertext without the Keystore key, so the store wipes itself and reports + signed-out. The secure-prefs file is excluded from backup in + `res/xml/backup_rules.xml` and `res/xml/data_extraction_rules.xml`. +- **The PKCE pair is PERSISTED**, not held in memory: iOS routinely kills the + backgrounded app while the user types their password in Safari. 10-minute TTL, + burned after one exchange attempt, and the `code` is deduped because + `app_links` 6.x delivers the cold-start URI on both `getInitialLink()` and + `uriLinkStream`. That dedupe is a **claim/release pair**: `Set.add` is the + atomic test-and-set at the guard (it must sit ahead of the first `await`), and + the claim is released again on every path that declines to exchange — no + pending pair, expired pair, state mismatch — so the genuine callback carrying + that same code is never locked out. +- **An `error=` callback is honoured only when it answers a live attempt**: a + pending pair must exist and its `state` must match, and the code is allowlisted + (`^[a-z_]{1,32}$`, anything else collapses to `denied`). A deep link is + unauthenticated — any app on the device can fire + `meshmapper-auth://callback?error=x`, which would otherwise destroy a + legitimate in-flight pair and push an arbitrary string into the UI. +- **A failed sign-in is reported by `MainScaffold`, not by the call site**: the + browser round trip outlives the Settings tap that started it, so + `onSignInComplete` lands with no live caller left to answer. The provider + parks the sanitized code in `portalSignInError`; the scaffold drains it on the + next frame, maps it to user-facing copy and calls `clearPortalSignInError()`. + Only failures attributable to an attempt THIS app started get that far — an + unsolicited callback (no pending pair, state mismatch) still returns silently + by design, for the same reason the `error=` rule above exists. +- **Linking**: `requestNonce(pubkey)` → the app validates the answer is exactly + 64 hex / 32 bytes → `MeshCoreConnection.sign()` has the radio Ed25519-sign the + **raw 32 bytes** → `linkDevice(pubkey, nonce, signature, label)` binds it. The + app lane NEVER auto-adopts placeholders: `adoption_required` shows a dialog + pointing at the browser portal. +- **The sign write gate**: `CMD_SIGN_DATA` is acked by a bare `OK (0x00)`, and so + are `setFloodScope`, `setChannel`, `setPathHashMode`, `setAdvertName` and + `setTxPower`. Every outbound frame in `connection.dart` therefore funnels + through one private `_write(bytes, {isSignFrame})`, which queues non-sign + frames behind `_signGate` for the few hundred ms a sign takes. `getChannel()` + used to bypass `_sendToRadio` with a direct `_transport.write` — it now goes + through the gate too. `disconnect()`, `deleteWardrivingChannelEarly()` and + `dispose()` all call `_abortPendingSign()`, and the provider's user-initiated + `disconnect()` calls the public `abortPendingSign()` **first**, before any + teardown write, so a live sign can never park the advert-name/path-hash + restore or the channel deletion behind its timeout. +- **When the prompt appears**: pure `decideLinkFlow()` + (`lib/services/link_decision.dart`) — signed in, not offline, not anonymous, + not auto-pinging, not auto-reconnecting, a pubkey exists, not declined, not + already linked, firmware can sign, and not already asked **this app session** + (per pubkey, so a BLE flap mid-drive cannot re-ask). Time-dependent backoff + and the 5-attempt cap live in `AppStateProvider` just before that call; the + dialog additionally gates on `isConnected`, since a pending prompt survives a + disconnect but the handshake needs a radio to sign. Control flow reads + `isPortalLoggedIn` (the live token), never `portalAccount != null` — the + cached account is a display name that outlives the token. +- **Two strikes before a radio is written off**: an `unsupported` SignException + bumps an in-memory strike and is persisted to `portal_sign_unsupported_devices` + only on the SECOND one, because a stats/battery poller ERR already in flight + when the sign starts is misattributed as `unsupported`. A server + `bad_signature` has its own dedicated 2-strike counter (the generic attempt + counter also holds nonce and network failures, so it cannot stand in). A local + sign that succeeds clears the unsupported strike; a `LinkSuccess` clears BOTH + counters and the persisted verdict. +- **A 429 is terminal and always carries `Retry-After`**: the portal's buckets + slide and a blocked request does NOT reset the count, it re-arms a FRESH + penalty (`me` is 12/hour with a 600s penalty, so a user who keeps tapping + extends their own lockout). `_postWithToken` parses the header (delta-seconds, + clamped to 1 hour, `PortalApi.defaultRetryAfter` = 5 min when it is missing or + unparseable) into a per-route block, readable as `rateLimitBackoff(route)` and + `linkLaneBackoff` (the longer of nonce/link). Three consumers: + `refreshMe(force: true)` skips the LOCAL hourly throttle but NEVER a server + block, and the Settings refresh button says how long to wait instead of + reprinting a stale device count; `logout` does not retry into a 429 and accepts + the orphaned server token; `_recordLinkFailure` takes the longer of its own + 30s..8m ladder and the server's value. A 429 is never a sign-out: 401 + + `token_invalid` stays the only signed-out signal. +- **Persistence** (Hive `user_preferences`): `portal_account_info`, + `portal_linked_pubkeys` (UPPER hex), `portal_link_declined_devices`, + `portal_sign_unsupported_devices`. Sign-out clears the first two and keeps the + last two — those are device preferences, not account data. +- **Logging**: everything is `[ACCOUNT]`, routed through a redactor that strips + any live token / verifier / state / code, and `DebugFileLogger.scrubSecrets()` + strips credential shapes from every line written to a log FILE (debug logging + is on in release builds and those files ship with bug reports). Public keys + appear as an 8-char prefix only. +- **Files**: `lib/utils/pkce.dart`, `lib/services/portal_token_store.dart`, + `lib/services/portal_account_service.dart`, `lib/services/link_decision.dart`, + `lib/services/meshcore/connection.dart` (`sign()`, `_write()`), + `lib/providers/app_state_provider.dart`, `lib/screens/main_scaffold.dart`, + `lib/screens/settings_screen.dart`. + +### Coverage Overlay (vector tiles) + +The MeshMapper coverage layer is rendered from the region server's vector tiles +(`vector_tile.php`, z7–14, overzoom beyond) as a MapLibre source+layer pair. The app is +vector-only — every region server must serve `vector_tile.php` (the legacy raster +`tiles.php` overlay was removed from the app 2026-06). Contract reference: +`MeshMapper_Server/docs/VECTOR_TILES.md`. + +- **Styling is client-side**: each cell carries an integer status category `st`; colours + come from `match` expressions built by `lib/utils/coverage_tile_palette.dart` (kept in + sync with the server's `dev/cvd_palettes.php`, including all colour-vision palettes). +- **Coverage Grid preference (`prefs.coverageGridSize`)**: Simplified (300 m, default) or + Detailed (100 m + blob), mirroring the web's Grid Mode; baked into the tile URL. The + grid is locked to the chosen preset at every zoom — cells never resize. +- **Post-wardrive live refresh**: on upload success the queue hands the uploaded items to + `AppStateProvider`; +7 s later the server re-renders the affected tiles at z11–14 + (`fresh=1`, incl. neighbouring tiles within ~0.005° — blob/border spill lands in the + next tile over), and the user's own cells are decoded from the fresh z14 bodies + (`lib/utils/mvt_cells.dart`) into a session **patch layer**: a GeoJSON source updated + in place above the base layer, with the base layer's copies hidden via `setFilter`. + The base source is never swapped — nothing visibly changes except the changed cells. + A second check runs at +10 s only when the first found no changes. Logged under + `[COVERAGE]`. +- **GOTCHA — never partial-update a fill layer**: `setLayerProperties` serializes with + `skipNulls: false`; any `FillLayerProperties` field left null is RESET to its + style-spec default on iOS/web (`fill-color` → black). Always resend the full colour + expressions with an opacity change (see `_applyCoverageOverlayOpacity`). +- **GOTCHA — feature ids don't survive Android's filter bridge**: the platform converter + parses filter JSON numbers as float32, which rounds the 42-bit cell ids. Filter on the + small-int `i`/`j` properties (as an `"i_j"` string) instead — see + `_applyBasePatchFilter`. +- **Files**: `lib/widgets/map_widget.dart` (`_addCoverageOverlay`, `_applyCoveragePatch`), + `lib/providers/app_state_provider.dart` (`_freshenAffectedVectorTiles`), + `lib/services/api_service.dart` (`freshenVectorTile`), + `lib/utils/coverage_tile_palette.dart`, `lib/utils/mvt_cells.dart`. + +### Coverage Connection Lines (tap-to-inspect) + +Tapping coverage data draws connection lines from points the tap flow ALREADY +fetches (no extra network calls), matching the web client's exact matching + +fan-out logic (`MeshMapper_Server/dev/index.php`): + +- **Tap a coverage tile (Feature A)**: fans out a theme-aware blue dashed line + from the cell centre to every UNIQUE repeater that heard the cell's pings (with + a distance pill per line) and hides the repeaters that didn't. Hooks the + blob-filtered points already computed in `_showCellSummary`. Port of + `updateAllActiveLines`/`updateActiveLinesInternal` via `heardEndpointsForCell`. +- **Tap a repeater (Feature B)**: draws the repeater's matched coverage cells + (status/tile-coloured fills, deduped per grid cell with highest-priority status + winning, red/DROP hidden) plus a status-coloured dashed line from the repeater + to each cell centre. The base coverage tiles DIM and every OTHER repeater is + hidden so the focused repeater's cells/lines pop (web `setSoloCircle` + + tile-dim parity); both restored on close. + Reuses the points fetched in `_showRepeaterDetails`. Port of + `buildChartFromPoints` (`RepeaterStats.fromCoverageWithPoints`) + + `drawRepeaterCoverageFromCache` (`repeaterCoverageCells`). +- **Volume cap**: both cap at the farthest 250 lines/cells (longest reach kept), + logged under `[COVERAGE]` when truncated. +- **Layers** (`map_widget.dart`): `coverage-lines-layer` (shared A/B, per-feature + `color`) and `coverage-cells-layer` (per-feature fill) — install-once empty, + updated via `setGeoJsonSource`, kept separate from the focus-mode lines so the + two features never wipe each other. Imperative draws (no `mapRevision` bump). + Teardown funnels through `_clearCellHighlight` (A) and `_clearRepeaterIsolation` + (B), which also restore the dimmed backdrop and the hidden/all repeaters. +- **Files**: `lib/widgets/map_widget.dart` (`_updateCoverageLines`, + `_updateCoverageCells`, `_drawRepeaterCoverage`, `_syncCoverageDistanceLabels`), + `lib/utils/coverage_summary.dart` (`heardEndpointsForCell`, + `repeaterCoverageCells`, `RepeaterStats.fromCoverageWithPoints`), + `lib/utils/coverage_tile_palette.dart` (`colorsForStatus`). + +### Apple Companion Surfaces (Watch + Live Activity) + +Both surfaces are **projections of phone-owned state**. The phone keeps the +MeshCore connection, the GPS fix, the session lifecycle, the transmit policy and +command admission; the wrist and the Live Activity render that state and send +intent back. Nothing on either surface may decide that a transmit is legal. + +`docs/LIVE_ACTIVITIES.md` covers the ActivityKit half. The invariants below +belong to the watch bridge, and breaking one of them costs battery on two +devices or puts a packet on air from the wrong place. + +**Delivery and suppression** (`lib/services/watch/watch_bridge_service.dart`) + +Every gate runs before the next, and each exists for a different failure: + +1. **Debounce** — 200 ms. Coalesces a burst of `notifyListeners()`. +2. **Urgency preflight** — the flush decides whether it may wait *before* + building anything. `WatchSnapshot.buildUrgencyKey` is a small scalar + projection (session, mode, phase, connection, control enablement, cue ID, + map-geo inclusion); if it hasn't moved, the 2 s floor applies and no + geography is constructed, sorted or encoded. `LiveActivityService` mirrors + this with `LiveActivitySnapshot.buildPreflightUrgencyKey` and a 15 s floor. + **Sustained per-tick work is the thing to avoid here** — the countdown + timers drive a flush at ~2 Hz for a whole session, and this app has a + wardriving overheat history. +3. **Payload fingerprint** — JSON of the payload minus `updatedAtMs`. Timestamp + metadata must never defeat dedupe; the watch renders countdowns from the + absolute `phaseEndsAt` deadline instead. An explicit `forceRefresh` is the + one thing that may send an identical payload again. +4. **Movement gate** — `WatchWire.minMoveMeters` (15 m). Expressed as "nothing + but the fix changed, and the fix didn't move far enough", measured against + the fix the watch last *received*. A refused or dropped send must not + consume the wearer's next 15 m. +5. **Send throttle** — the same 2 s floor, applied to delivery. A forced + refresh outlives a deferral rather than being dropped. + +Urgent updates use `sendMessage` and *always* fall through to +`updateApplicationContext`, so a missed message can't strand the watch. + +**The two paths are not ordered against each other, and the watch enforces +that.** `sendMessage` does not populate `receivedApplicationContext`, so the +retained context can hold a payload the watch already superseded live — and +`resume` ingests that context on every wrist raise. `WatchSessionClient.apply` +therefore refuses anything whose `updatedAt` predates what is already rendered. +Both stamps come from the one phone clock, so they compare raw. The refusal is +lifted once the held snapshot is stale, which bounds a backwards clock step to +90 seconds of refusal instead of the life of the process. There is deliberately +no wire `seq`: a counter restarting at zero is indistinguishable from an ancient +one without a process identity beside it, which is a version conversation for +behaviour this already has. + +**Cache invalidation.** Dart's dedupe caches mirror native's `lastContextData`. +Native clears that only in `sessionWatchStateDidChange` and on `clear`, and says +so with `nativeCacheCleared` on the `availabilityChanged` push. Reachability +flips on every wrist raise and lower — treating those as invalidation forces a +full context resend per glance and voids the map-geo lease. + +**Map-geo lease.** While the map isn't visible the watch asks the phone to omit +geography. The phone treats that as a *lease*, not a latch: suppression expires +after `_mapGeoClaimFreshFor` (10 min) back to full geography, and the watch +renews it every 5 minutes. A lost command therefore fails safe — toward sending +too much rather than a permanently blank map. Renewals are deduplicatable; +only a stated `forceRefresh` defeats the payload fingerprint. + +**Command admission.** Wrist commands are intent, revalidated by the phone. +`transferUserInfo` is the *only* transport — there is deliberately no +`sendMessage` path into admission, because that can execute a command and still +fail its reply as undeliverable, leaving the wrist unable to tell a refusal from +a lost ack. + +- IDs make WatchConnectivity's redelivery idempotent. A *queued* command refused + once stays remembered — redelivery after conditions change must never turn + yesterday's tap into a transmit. Only an untimestamped command forgets, since + it cannot be aged and its sender may legitimately retry. +- A redelivery is answered with the **outcome recorded the first time**, not a + blanket acceptance. Replying "accepted" to the redelivery of something the + bridge refused describes a transmit that never happened. +- Timestamped commands must land inside `_maximumCommandAge` (30 s), with + `_clockTolerance` (5 s) of slack in both directions. +- **The two devices do not share a clock, and that is a normal condition.** + `issuedAtMs` is stamped in the watch's clock and the command carries + `clockOffsetMs` beside it, so the phone measures a real elapsed age rather + than an age plus the skew. The watch learns that offset only from a live + `sendMessage`, whose transit is milliseconds; an application context may have + sat retained for hours and says nothing about the current offset. Absent + offset means zero, which is the old behaviour exactly. `_clockTolerance` now + covers the residual — transit and measurement error — not the skew itself. + The offset is *not* folded into `issuedAtMs`, because that value doubles as + the ordering key for map-geo suppression claims and rewriting it would make + the key jump backwards the first time an offset is learned. +- `requestSnapshot` and `stopSession` are exempt from the age window: one + transmits nothing, and the other takes the radio *off* air, so lateness can + only make refusing it worse. **A stop therefore names its session**, from the + snapshot the wearer was looking at when they tapped, and is refused if the + phone has since moved on. Without that the exemption assumed one session was + as good as another, and a stop queued against A could silently end B. The + field is optional: absent means an older watch build and is admitted as + before, because refusing those would strand a wearer whose Stop button the + phone had quietly stopped honouring. +- `resolveSessionStartAvailability` is the single start gate for both the + offered button and the admitted command. **Passive counts as transmitting** — + it sends a discovery request on start and every 30 s — so the manual-ping, + RX-window and cooldown guards apply to every mode. Only offline mode, + passive-only zones, flood traffic being off, and TX validation are + transmit-only. +- **Flood traffic is an existence policy, not a preference.** The phone builds + Send Ping and the Active/Hybrid button inside + `if (!txNotAllowed && floodTrafficVisible)`, so with flood off those controls + do not exist — and `floodTrafficEnabled` folds in the regional + `flood_disabled` veto a zone admin sets. It gates the wrist on both sides: + `resolveAvailableWatchStartModes` withdraws Hybrid, and + `resolveSessionStartAvailability` plus `_manualPingAvailability` refuse with + 'Flood Traffic Off'. The preference **defaults off**, so a wrist that skips + this admits the common configuration rather than an edge one. + +**Failure cues.** A one-shot cue rides *every* snapshot until it is older than +`WatchWire.cueReadableFor` (90 s), which mirrors `WatchSessionClient.staleAfter`. +It is deliberately **not** dropped when native accepts a payload carrying it: +that reply means `updateApplicationContext` took the blob, not that the watch +ingested it, and the wearer's wrist is usually down at that moment. Because the +cue ID is in the urgency key, dropping it there made the very next flush urgent +and overwrote the retained context with a cue-less payload — so a suspended +watch woke to idle UI and no account of the failure. Re-attaching is free: the +watch keys haptics on `presentedCueIDs` and drops the cue itself past the +boundary rather than asserting a dead failure as current. + +`presentedCueIDs` is process-local, so that de-duplication covers WatchConnectivity +redelivery but **not** a watch process that dies and relaunches. Launch ingests +the retained context, and a cue still inside `cueFreshFor` (30 s) buzzes again +against an empty set. Widening the attachment window from about a second to 90 s +widened that case with it — deliberately. One duplicate haptic after a relaunch +is a far smaller failure than the silence it replaced, and closing it properly +means persisting presented IDs across launches for a payload the watch is +already re-reading on purpose. + +**Wire versioning** (`WatchWire.version`, mirrored in +`ios/Shared/MeshMapperWatchPayload.swift`) + +Bump only when a field **changes meaning or is removed**. Additive optional +fields must not bump it: a bump strands compatible pairs, and older peers are +required to default absent fields safely. The watch reads `wireVersion` with a +minimal probe struct *before* attempting the full decode, so a payload that a +future breaking change makes undecodable still reaches the "update the iPhone +app" prompt instead of going silently stale. + +**Geography caps** — `maxPings` 60, `maxRepeaters` 20, `maxHeard` 4. Applied by +`WatchGeoBuilder` on the sending side, after merging every source and sorting by +recency, so a busy TX history cannot erase discovery or trace markers. + +**Heard-row names** resolve from the fullest identity each row arrived with, +via `WatchGeoBuilder.resolveOverlayRepeaters`. A path hash is 1–3 bytes and in a +busy zone routinely matches several repeaters, where naming one would be a coin +flip — but the phone often knows exactly who answered: a discovery response +carries the responder's full 64-character public key, and a trace carries its +4-byte target. Those identities travel beside the overlay rows in +`_overlayIdentityById`, **replaced wholesale per ping and never merged**, because +a hash that meant one repeater in a discovery response says nothing about who a +later TX echo under the same hash was. + +TX echoes and passive RX carry only the path byte, so they fall back to prefix +matching and keep refusing to guess. That fallback indexes per *distinct* +prefix length: the RX slot's hash can be a different width than the top rows', +so a single-length index silently drops the odd row's name and distance. + +Uniqueness is required at every step. A longer identity makes a collision +vanishingly unlikely, not impossible, and a confidently wrong name stays worse +than none. **Never resolve names on the watch** — it holds only the nearest 20 +repeaters, so it would name rows the phone refused as ambiguous across the full +catalogue. + +**Live Activity host support.** ActivityKit answers `sync` with `true`, `false`, +or `"unsupported"`. `false` means *not right now* — authorization is off, or +`Activity.request` was refused because the app is backgrounded — and earns the +30 s backoff. `"unsupported"` (and a `MissingPluginException`) means this host +can never show one, and Dart stops asking for the rest of the process rather +than running a guaranteed-fail retry loop all session. + +**App Intents, Siri, and future Apple surfaces.** Every App Intent in this app +is iOS 26+: the extension has a 26.0 deployment target and the Runner intent +types are annotated `@available(iOS 26.0, *)`. Devices below that get none of +these Siri actions, and none of the app's other surfaces depend on them. + +Mutation intents live in the +Runner process because session and connection changes must pass through the +same phone-owned admission path as the watch. Read-only intents *run* in a +separate extension against the bounded App Group snapshot in +`ios/Shared/AppIntents/`; they must not launch Flutter. + +**There is exactly one `AppShortcutsProvider`, `MeshMapperAppShortcuts`, and it +lives in the Runner target.** App Shortcuts are indexed from the app, so a +provider inside the App Intents extension is never registered and its phrases +silently do nothing when spoken: no error, just a pause and no result. Apple +additionally requires that every intent a provider names is a member of the +*same* target as the provider, which is why the read intents and their entities +(`MeshMapperReadIntents.swift`, `RepeaterEntity.swift`, +`HeardRepeaterEntity.swift`) are compiled into Runner **and** the extension. +Dual target membership is Apple's documented arrangement for an intent that +backs an App Shortcut and must also run in an extension; a shared framework is +explicitly not an option for these. The provider is capped at ten shortcuts +(five are used), and exceeding it is a compile error. + +When adding a read intent: add its file to both targets, and add the shortcut to +`MeshMapperAppShortcuts`, never to a second provider. Despite its historical `Siri` type names and +`siri-snapshot.json` filename, that Foundation-only snapshot is the reusable +low-frequency contract for future native glance surfaces. + +Keep future targets separated by lifecycle: + +- A widget may read the App Group snapshot directly and add only optional, + bounded fields to wire version 1. A meaning change or removal requires a + coordinated version bump. +- The last valid snapshot deliberately survives Runner termination. Read + intents qualify it by age; process teardown is not proof that a background + session stopped, and `dispose()` is not a reliable iOS lifecycle callback. +- Adopt `IndexedEntity` only with an explicit owner that calls + `indexAppEntities` and removes stale entries. Conformance alone does not put + repeaters in Spotlight. +- A CarPlay map scene needs its own native scene/target, entitlement and + foreground lifecycle. It may bootstrap from the shared snapshot, but live + location/map updates need a dedicated bridge rather than polling the Siri + file or importing `SiriIntentCoordinator`. +- Every added target gets its own bundle ID, App Group entitlement and explicit + App Store provisioning-profile entry. Do not put target-specific frameworks + or lifecycle into the shared snapshot model. +- Native controls express intent; Dart remains the owner of connection, + session and radio-policy admission. + +**One deadline, both sides.** `SiriIntentCoordinator` stops waiting after +`SiriCommand.responseTimeout` (10 s, or 30 s for Connect) and tells the person +it failed. That same instant travels to Dart as `expiresAtMs`, so giving up is +one decision rather than two, and it is rechecked at three points: + +1. Admission refuses an already-expired command outright. +2. `toggleAutoPing`/`sendPing` recheck after the awaited session check, before + any existing mode is torn down. +3. `PingService` rechecks in `sendTxPing` and `_sendDiscoveryRequest` after + **both** of the unbounded waits that precede a transmission, and before any + of the early returns that follow, not just before the BLE call. Nothing + between that check and the wire awaits, so it is the send instant in + wall-clock terms while leaving no `TxPing`/`DiscLogEntry` record and no + consumed wire-tag counter behind for a transmission never made. + + Both details are load-bearing, and each was got wrong once: + + - **Ahead of the early returns.** The `null`-position and "too close to last + discovery" returns call `_scheduleNextDiscovery()` and report success, so a + check below them lets a GPS fix that crossed the deadline start a session + that transmits on the next tick anyway. + - **After the write gate, not just after GPS.** `MeshCoreConnection._write` + parks non-sign frames behind an in-progress `CMD_SIGN` (five seconds per + phase, chunk phase looping), and that wait is unbounded by design ("delayed, + never failed"). It sits *inside* the send call, past everything a caller can + check. `awaitWritableState()` exists so a deadline-carrying caller takes + that wait where abandoning is still free; it is awaited only on that path, + so ordinary pings keep queuing exactly as before. + +Point 3 is the one that matters: Passive and Hybrid await a GPS fix *inside* +`enableAutoPing`, so points 1 and 2 alone would let the radio key up after Siri +had already reported cancellation. When the gate fires there, +`_abandonAutoPingStart()` unwinds the half-started session and `enableAutoPing` +returns false, so the session does not come up either. Active mode's initial +ping is awaited only when a gate is supplied, so the ordinary start path keeps +its existing fire-and-forget timing. + +`PingService.transmitAbortedByDeadline` distinguishes "the caller had already +given up" from the ordinary reasons a send is skipped: cooldown, failed +validation, no GPS. It is reset on entry to every gated path, and both the start +and the manual-ping paths read it so the refusal says the request arrived too +late rather than the generic "couldn't send the ping". + +The gate reaches only the session's *first* transmission; later pings come from +timers and belong to the session, not to the surface that started it. Checks 2 +and 3 also close `externalCommandCommitMargin` early, because work that has not +begun cannot finish inside a deadline that is nearly gone; check 1 applies no +margin and refuses only a command that has already expired. Stop stays exempt +throughout; stopping is the safe direction, and a safe-direction command must +never be abandoned because a voice request timed out. A surface that sends no +`expiresAtMs`, such as the watch, still falls back to the shared 30-second +`maximumExternalCommandAge`. + +**Connect's 30 seconds is a response deadline, not a cancellation guarantee.** +This is the one mutation where the two differ, and the difference is deliberate. + +Up to the point of dialling, Connect behaves like the rest: +`_connectToLastCompanion` checks the deadline as soon as the 10-second readiness +wait ends (a cold launch can spend that whole budget before admission has even +run), `resolveLastCompanionConnection` then checks `expiresAt` ahead of the age +rule and ahead of every state-based refusal, and it requires +`externalCommandCommitMargin` before dialling. Refusing there is free and avoids +pointless transport churn. + +Once dialling starts there is no way back: `connectToDevice`/`connectViaTcp` +have no cancellation seam, and a BLE GATT phase alone may run 15 seconds before +protocol setup and authentication. A reconnect begun with ~20 seconds left can +therefore finish after the intent has given up; that is ordinary, not an edge +case, which is why the preflight margin is a sanity check rather than a +guarantee. Such a reconnect is deliberately left connected: it transmits nothing +on the mesh, starts no session, and is what the person asked for; tearing it +down would only make them wait out another cold reconnect. + +Because of that, `SiriCommand.Kind.timeoutMessage` is per-kind, and the rule is +that only a kind which really is cancelled may say so: + +| Kind | On timeout | Why | +| --- | --- | --- | +| Start, Manual Ping | "…the request was cancelled" | True: Dart holds the same deadline and checks it before every RF send. | +| Connect | "…may still be connecting" | No cancellation seam once a transport is dialling. | +| Stop | "…may still be stopping" | Deliberately deadline-exempt, and teardown may queue a pending disable behind an RX window. | + +**Adding a kind means deciding which column it belongs in.** Connect earns the +first wording only if `connectToDevice`/`connectViaTcp` gain a cancellation +seam; Stop only if it is made cancellable, which it should not be. Until then, +do not change either message back. + +**"Current session" needs a session boundary.** The observation history is +bounded at two hours, which routinely spans several sessions. `session.startedAt` +carries the running session's start so the Recent Repeaters intent can filter +against it, and `uniqueRepeatersHeard` counts only observations at or after it; +with nothing running both report empty rather than borrowing the previous +session's results. + +The boundary must be captured *before* the session's first transmission, not +after it: `enableAutoPing()` sends and records the opening discovery before it +returns, so taking the timestamp afterwards would push a Passive or Hybrid +session's own first observation outside its boundary. `toggleAutoPing` therefore +reads the clock before the call and passes it to `_startLiveActivitySession`, +and the filter is inclusive of that instant. A manual ping that is later upgraded +to an automatic mode deliberately keeps its original boundary: that is one +session under one ID, and the manual ping and its RX window are that session's +own results. Any surface that says "current" or "this session" must also +check `updatedAt` against `MeshMapperSnapshotFreshness.currentClaimLimit`; the +snapshot deliberately outlives Runner and can be days old. + +The snapshot contains at most 64 recent observations and 64 repeaters. That +catalogue bound is also the entity-lookup bound: `RepeaterEntityQuery` searches +only the cached active/recent entries, never the full loaded repeater set, which +is why the intent is named "Find Recent MeshMapper Repeater". Widening the +lookup means widening the catalogue or adding a separate compact index. Do not +leave a broad name over a narrow index, and keep that in mind when reviving the +withdrawn phrase, since an empty or stale catalogue is one candidate cause of +the lookup failure. Recent +observations are ranked and truncated before catalogue identity resolution, so +large histories do not multiply the resolution work. A cheap scalar/revision +preflight key suppresses rebuilds when provider notifications do not change +the native projection. Preserve both bounds and the preflight path when adding +fields for another Apple surface. + +The built-in voice phrases are refreshed at app launch. After installing an +update, open MeshMapper once, then use any of these forms (the app name is part +of every registered phrase): + +- `Siri, reconnect MeshMapper` or `Siri, connect MeshMapper to the last device`. +- `Siri, start MeshMapper` defaults to Passive Discovery. +- `Siri, start a Passive Discovery session in MeshMapper`. +- `Siri, start Active mode in MeshMapper`. +- `Siri, start Hybrid mapping with MeshMapper`. +- `Siri, stop MeshMapper`. +- `Siri, what is MeshMapper doing?` or `Siri, get MeshMapper status`. +- `Siri, what has MeshMapper heard?` or `Siri, recent repeaters in MeshMapper`. + +Repeater lookup by name has no spoken phrase. `FindMeshMapperRepeaterIntent` +ships and can be used from the Shortcuts app, but spoken lookup did not work on +device and its `AppShortcut` is withdrawn until it does (see the TODO on the +intent). Adding the phrase back means adding the bullet back here. + +Mutation intents return their completion message both as Siri dialog and as a +text output. A user-created Shortcut can pass that Result to a `Speak Text` +action when explicit audio is required. Direct Siri invocations normally speak +the dialog, but iOS still honors the system Siri Responses setting; select +Prefer Spoken Responses when voice feedback is required even in Silent mode. + +Connect and Start require device authentication and may bring the app forward. +Connect reuses the last remembered BLE/TCP companion; USB still requires an +in-app selection. Connect can also outlast Siri's 30-second wait: a slow radio +may finish connecting after Siri has stopped listening, which is why its timeout +says the app may still be connecting rather than that the request was cancelled. +Starting does not silently change companions or reconnect; ask to connect first +when MeshMapper is disconnected. ### BLE Service UUIDs (MeshCore Companion Protocol) - Service: `6E400001-B5A3-F393-E0A9-E50E24DCCA9E` @@ -320,15 +985,19 @@ Keeps the screen on during auto-ping to prevent device sleep during wardriving s ### Android - Requires permissions: Bluetooth, Location (for BLE scanning) -- minSdkVersion: 21 (Android 5.0+) +- minSdkVersion: 24 (Flutter's `flutter.minSdkVersion` default; MapLibre GL needs 23+) +- Supports Android Auto. Testing needs Desktop Head Unit, and can not be tested against a physical head unit. - Background location permission for continuous tracking - Uses `flutter_blue_plus` package +- URL scheme `meshmapper-auth` (host `callback`) registered on MainActivity via a VIEW/DEFAULT/BROWSABLE intent-filter — the portal sign-in return. The bare `meshmapper://` scheme is deliberately NOT registered: it is a paste-only clipboard format (`docs/CUSTOM_API_ENDPOINT.md`). +- `android:fullBackupContent` / `android:dataExtractionRules` exclude the `MeshMapperSecure` secure-prefs file from backup ### iOS - Requires Info.plist entries: NSBluetoothAlwaysUsageDescription, NSLocationWhenInUseUsageDescription -- Deployment target: 12.0+ +- Deployment target: 13.0 - Background modes: bluetooth-central, location - Uses `flutter_blue_plus` package +- `CFBundleURLTypes` registers the `meshmapper-auth` scheme (name `net.meshmapper.app.auth`) for the portal sign-in return ## Dependencies @@ -336,12 +1005,152 @@ Key packages used in this project: - `flutter_blue_plus`: Mobile Bluetooth (Android/iOS) - `flutter_web_bluetooth`: Web Bluetooth (Chrome/Edge) +- `flutter_carplay`: Android Auto templates — **vendored & patched**, see below - `geolocator`: GPS/Location -- `flutter_map`: Map rendering +- `maplibre_gl`: Map rendering (MapLibre GL vector tiles via OpenFreeMap) — **vendored & patched**, see below - `hive`: Local storage - `provider`: State management - `http`: API requests - `pointycastle`: Encryption (AES-ECB, SHA-256) +- `usb_serial`: USB Serial communication on Android (USB OTG) +- `app_links`: Custom-scheme deep links (`meshmapper-auth://callback`) for the portal sign-in return +- `flutter_secure_storage`: Keychain / Android Keystore storage for the portal app token + +### Vendored `maplibre_gl` (`third_party/maplibre_gl`) + +`maplibre_gl` is consumed from an in-repo copy of the pub.dev `0.25.0` release via +`dependency_overrides` in `pubspec.yaml`, **not** from pub. The ONLY delta from upstream is a +native camera-viewport guard. + +**Why:** MapLibre's transform unprojects against the live viewport. When the GL surface is +degenerate/zero-sized (e.g. a launch where tiles never finish loading, so the surface never renders +a real frame), the very first animated `flyTo`/`setCamera` makes `unproject` produce NaN, and +`mbgl::LatLng`'s constructor throws an **uncaught C++ `std::domain_error` → SIGABRT**. That throw +crosses the Obj-C++→Swift/JNI boundary and **cannot be caught from Dart**, so the only place it can +be reliably stopped is inside the plugin's camera handlers. + +**The patch:** the `camera#animate` / `camera#move` / `camera#ease` cases in +`MapLibreMapController.swift` (iOS) and `MapLibreMapController.java` (Android) bail (completing the +method-channel result so the Dart `await` returns) when the map view has no usable size +(`bounds.width/height < 1` / `getWidth()/getHeight() < 1`). Search the patch with the tag +`MESHMAPPER GUARD`. + +The Dart side (`map_widget.dart`) is defense-in-depth: `_mapHasRenderedOnce` (set on the first +`onMapIdle`) is folded into `_canAnimateCamera`, so no programmatic camera move is even attempted +until the map has rendered once; the one-shot initial GPS zoom re-attempts on later ticks instead of +burning. See the `_canAnimateCamera` getter and `_onMapIdle`. + +**On upgrade:** re-apply the `MESHMAPPER GUARD` blocks to the new plugin version (or drop the +override if upstream gains an equivalent guard). + +### Vendored `flutter_carplay` (`third_party/flutter_carplay`) + +Consumed from an in-repo copy of the pub.dev `1.6.5` release via `dependency_overrides`, **not** +from pub. Despite the name it is used for its **Android Auto** half only — CarPlay is not shipped. +Three deltas from upstream, each tagged `DELTA` in the vendored source: + +- **DELTA A** (`third_party/flutter_carplay/pubspec.yaml`): the `ios:` plugin platform entry and + the whole `ios/` directory are removed. Upstream would link `SwiftFlutterCarplayPlugin` into the + App Store build — a CarPlay scene-delegate and entitlement surface we neither want nor hold + Apple's CarPlay entitlement for. The Dart `AA*` classes are pure Dart plus a MethodChannel and + still compile on iOS, where `AndroidAutoService.isSupportedPlatform` is false. +- **DELTA B** (`AndroidAutoService.kt`): adds `FAAEngineProvider`, letting the host app supply the + engine. Upstream builds a bare headless `FlutterEngine` whenever the cache is empty, which in + this app is the **wrong** engine — ours owns the USB serial and tile cache channels (see + `MeshMapperEngine.kt`), and two engines means two copies of every plugin fighting over this + plugin's own static template state. +- **DELTA C** (`AndroidAutoService.kt`): `createHostValidator()` no longer returns + `ALLOW_ALL_HOSTS_VALIDATOR` unconditionally. Android documents that as debug-only — it lets any + app on the device bind the service and drive the car surface — so release builds validate against + the car-app library's bundled host allowlist. Debug builds keep the permissive path, which is + what the Desktop Head Unit needs. +- **DELTA D** (`FlutterAndroidAutoPlugin.kt`, `AndroidAutoService.kt`, `Session.kt`, + `lib/aa_models/map/`): adds `MapWithContentTemplate`, its map action strip + (`MapController` + `AAMapAction` + an `onMapActionPressed` event), and a `FAASurfaceProvider` + hook. It also makes `AAPaneTemplate`'s title optional: upstream requires a + non-empty one, but `PaneTemplate.Builder.build()` does not — it validates only rows and + actions — and the title is what draws the header. Upstream + supports six templates, none of which can show a map. The hook lets the host app supply the + `SurfaceCallback`, so MeshMapper's MapLibre renderer lives in the app rather than teaching the + plugin about a map SDK — the same shape as DELTA B. + +`example/`, `previews/` and `test/` are not vendored. + +**On upgrade:** re-apply all three deltas. + +### Android Auto (`lib/services/auto/`) + +The coverage map on the head unit, plus a glance at session state, counters, and one-touch +start/stop. Category is **POI** (`androidx.car.app.category.POI`), because drawing a map is limited +to the navigation, POI and weather categories. + +```bash +adb shell cmd package query-services -a androidx.car.app.CarAppService | grep -A3 meshmapper +``` + +Expect `AndroidAutoService`, `exported=true`, `enabled=true`, and +`Category: "androidx.car.app.category.IOT"`. + +**One-time phone setup.** In the Android Auto settings screen, tap the version header 10× to unlock +developer mode, then from the ⋮ menu: + +- **Unknown sources** — ON. Required. An unpublished car app is *not listed at all* without it. +- **Start head unit server**. + +**After every install of a changed build** — including every `flutter run` — restart the Android +Auto session so it rescans: + +```bash +adb shell am force-stop com.google.android.projection.gearhead +``` + +Not a superstition. Android Auto builds its car-app list by querying `PackageManager` when a +session starts and caches it; `dumpsys package com.google.android.projection.gearhead` shows it +registers only `MY_PACKAGE_REPLACED` (for itself) and **no** `PACKAGE_ADDED`/`PACKAGE_CHANGED` +receiver for other packages. It therefore cannot notice a car app installed after it started. A +freshly installed or updated MeshMapper stays invisible until Android Auto is restarted. + +**Connect:** + +```bash +adb forward tcp:5277 tcp:5277 +$ANDROID_HOME/extras/google/auto/desktop-head-unit # ~/Android/Sdk/extras/google/auto/ +``` + +The phone screen must be unlocked. MeshMapper appears in the DHU launcher and opens to the four-row +pane — briefly a loading spinner first if Dart has not published a template yet, since the plugin's +`MainScreen.onGetTemplate` falls back to a loading `ListTemplate`. + +**`flutter run` and the DHU.** The DHU is **not** a Flutter device: it never appears in +`flutter devices` and is never a `flutter run` target. `flutter run` targets the phone; the car pane +is drawn by that same isolate. + +**Order matters.** Run `flutter run` *first*, then connect the DHU: `MainActivity` creates the +engine, `flutter run` attaches to it, and `FAAEngineProvider` (DELTA B) hands that same engine to +the car service — so **hot reload reaches the pane**. Connect the DHU first and Dart starts headless +in a process `flutter run` never launched; use `flutter attach` to pick it up. + +Debug builds accept any car host, because DELTA C keeps `ALLOW_ALL_HOSTS_VALIDATOR` for debuggable +builds — that is what lets the DHU bind. Release builds validate against the car-app library's +bundled allowlist, which Android Auto is on, so the DHU works there too. + +**The two checks that matter:** + +- **Template quota.** Turn on **Developer settings → Enable debug overlay** and watch the template + counter across a full auto-ping session. If counter updates consume steps, the row layout is + wrong — see the fixed-layout contract above. +- **One engine.** Cold start with `adb shell am force-stop net.meshmapper.app`, then connect. + `[APP] MeshMapper starting...` must appear in logcat **exactly once**. Twice means a second engine + and the `MeshMapperEngine` ownership rule has a hole. + +**Logs:** `adb logcat -s CarApp.H CarApp.H.Dis flutter`, after +`adb shell setprop log.tag.CarApp.H.Dis VERBOSE`. + +**Play submission:** car support needs Google's Android Auto review, declared in Play Console → +*Declare car compatibility* → POI. While a car submission is under review, subsequent app updates +are blocked — so ship it in its own release, not bundled with an urgent fix. Everything +car-specific is one `` block in the manifest plus `lib/services/auto/`; deleting the +service element disables the surface without touching Dart. ## Development Workflow Requirements @@ -366,6 +1175,8 @@ debugError('[API] Failed to post batch: $error'); |-----|-------------| | `[BLE]` | Bluetooth connection and device communication | | `[CONN]` | MeshCore connection protocol operations | +| `[SIRI]` | Siri App Intents bridge and snapshot publishing | +| `[EXTERNAL]` | External command execution (shared Siri/watch lane) | | `[GPS]` | GPS/geolocation operations | | `[PING]` | Ping sending and validation | | `[API QUEUE]` | API queue operations (batch posting) | @@ -398,6 +1209,9 @@ debugError('[API] Failed to post batch: $error'); | `[OFFLINE]` | Offline mode operations | | `[SCAN]` | BLE device scanning | | `[WAKELOCK]` | Wake lock acquisition/release | +| `[WATCH]` | WatchConnectivity bridge: availability, snapshot delivery, wrist commands | +| `[LIVE ACTIVITY]` | ActivityKit bridge: sync, end, and authorization failures | +| `[ACCOUNT]` | MyMeshMapper portal sign-in and companion device linking | Never log without a tag. @@ -506,6 +1320,12 @@ All API endpoints may return maintenance mode: - `lib/services/meshcore/tx_tracker.dart` - Repeater echo detection (7s window) - `lib/services/meshcore/disc_tracker.dart` - Discovery response tracking (7s window) - `lib/services/meshcore/rx_logger.dart` - Passive observation logging +- `lib/services/transport/companion_transport.dart` - Transport-agnostic interface for companion connections +- `lib/services/transport/stream_frame_codec.dart` - TCP/USB Serial framing codec +- `lib/services/transport/stream_transport_base.dart` - Shared base for TCP/USB Serial transports +- `lib/services/transport/tcp_service.dart` - TCP socket transport with saved connections +- `lib/services/transport/android_serial_service.dart` - USB Serial transport for Android (USB OTG) +- `lib/services/transport/web_serial_service.dart` - USB Serial transport for Web (Web Serial API) - `lib/services/ping_service.dart` - TX/RX/Discovery ping orchestration - `lib/services/gps_service.dart` - GPS tracking and geofencing - `lib/services/api_queue_service.dart` - Persistent upload queue @@ -517,6 +1337,33 @@ All API endpoints may return maintenance mode: - `lib/services/debug_submit_service.dart` - Bug report submission (4-step workflow) - `lib/services/gps_simulator_service.dart` - GPS simulation for testing - `lib/services/wakelock_service.dart` - Screen wake lock during auto-ping +- `lib/services/portal_account_service.dart` - MyMeshMapper portal lane (PKCE sign-in, nonce/link/unlink/me/logout) +- `lib/services/portal_token_store.dart` - Keychain/Keystore storage for the portal token and pending PKCE pair +- `lib/services/link_decision.dart` - Pure decision for whether to offer a device link +- `lib/utils/pkce.dart` - RFC 7636 S256 PKCE pair generation +- `lib/services/watch/watch_bridge_service.dart` - WatchConnectivity transport: throttle, dedupe, movement gate, map-geo lease, command admission +- `lib/services/watch/watch_models.dart` - Watch wire contract and shared start-admission resolver +- `lib/services/watch/watch_geo_builder.dart` - Ping/repeater/heard geography for the wrist, with wire caps +- `lib/services/watch/watch_color.dart` - Wire colour projection shared with the phone map +- `lib/services/live_activity/live_activity_service.dart` - ActivityKit bridge: preflight urgency, throttle, dedupe, unavailable backoff +- `lib/services/live_activity/live_activity_models.dart` - Live Activity snapshot model and urgency keys +- `lib/services/external_surfaces/external_surface_publisher.dart` - Shared publish pipeline (preflight dedupe, throttle, retry) behind watch, Live Activity, Siri snapshots, and the Android Auto pane +- `lib/services/external_surfaces/geo/external_surface_geo_builder.dart` - Ping/repeater/heard geography for external surfaces, with wire caps (was watch_geo_builder) +- `lib/services/external_commands/external_session_commands.dart` - Shared Siri/watch/car session-command admission and deadline rules +- `lib/services/external_commands/external_command_models.dart` - External command wire model, refusal reasons, and voice copy +- `lib/services/app_intents/app_intent_bridge_service.dart` - Siri method channel: command decode, dedupe, snapshot publish +- `lib/services/app_intents/siri_snapshot_builder.dart` - App Group snapshot content (recent heard, repeater catalogue, counts) +- `lib/services/app_intents/last_companion_connection.dart` - Connect-last-companion admission for the Siri intent +- `lib/services/auto/android_auto_service.dart` - Android Auto surface: connection lifecycle, pane publisher, serialized map sync, action routing +- `lib/services/auto/auto_glance_view.dart` - Pure WatchSnapshot to head-unit-pane projection (fixed 4-row layout) +- `lib/services/auto/car_map_channel.dart` - Dart side of the car map: camera, style and coverage overlay, deduped +- `android/app/src/main/kotlin/net/meshmapper/app/MeshMapperCarMap.kt` - Native MapLibre map on the car Surface (VirtualDisplay + Presentation) +- `android/app/src/main/kotlin/net/meshmapper/app/MeshMapperCarMapChannel.kt` - Holds the renderer and bridges it to Dart +- `android/app/src/main/kotlin/net/meshmapper/app/CarMapCoverage.kt` - Tile URL plus finished paint expressions, as Dart describes them +- `android/app/src/main/kotlin/net/meshmapper/app/CarMapTimerBar.kt` - The depleting next-ping bar, animated locally from a deadline +- `android/app/src/main/kotlin/net/meshmapper/app/MeshMapperEngine.kt` - Sole owner of the process FlutterEngine and its app-scoped channels +- `android/app/src/main/kotlin/net/meshmapper/app/MeshMapperApplication.kt` - Installs the engine factory the car service uses +- `lib/screens/watch_diagnostics_screen.dart` - Watch transport diagnostics (Settings) - `lib/services/meshcore/packet_validator.dart` - Packet validation and carpeater filtering - `lib/models/noise_floor_session.dart` - Noise floor session data models - `lib/widgets/noise_floor_chart.dart` - Noise floor graph visualization diff --git a/README.md b/README.md index dc09548a..f7951a3d 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,6 @@ Built with contributions by **The Greater Ottawa Mesh Radio Enthusiasts** **App Store Releases** - **Android:** [Google Play](https://play.google.com/store/apps/details?id=net.meshmapper.app) or grab the [APK from GitHub](https://github.com/MeshMapper/MeshMapper_Project/releases/) - **iOS:** [App Store](https://apps.apple.com/us/app/meshmapper/id6758073991) -- **Web:** [wd.meshmapper.net](https://wd.meshmapper.net) (Chrome/Edge only) **Beta Releases** - **iOS:** [TestFlight](https://testflight.apple.com/join/PXxfr5Jr) @@ -27,7 +26,7 @@ Built with contributions by **The Greater Ottawa Mesh Radio Enthusiasts** ## Features -- **Cross-Platform** — Android, iOS, and Web (Chrome/Edge) +- **Cross-Platform** — Android and iOS - **BLE Connectivity** — Connect to MeshCore companion devices via Bluetooth Low Energy - **GPS Tracking** — High-accuracy location tracking with 50m distance filter - **Real-time Map** — View TX/RX/DISC ping markers on OpenStreetMap with dark mode tiles @@ -97,4 +96,5 @@ This project is licensed under the MIT License — see [LICENSE](LICENSE) for de - Original [MeshMapper WebClient](https://github.com/MeshMapper/MeshMapper_WebClient) - [MeshCore](https://github.com/meshcore-dev/MeshCore) firmware project +- [meshcore-open](https://github.com/zjs81/meshcore-open) by zjs81 — Android USB Serial support is based heavily on its native USB host implementation (MIT License, see [THIRD_PARTY_LICENSES](THIRD_PARTY_LICENSES)) - [The Greater Ottawa Mesh Radio Enthusiasts community](https://ottawamesh.ca/) diff --git a/THIRD_PARTY_LICENSES b/THIRD_PARTY_LICENSES new file mode 100644 index 00000000..a542052e --- /dev/null +++ b/THIRD_PARTY_LICENSES @@ -0,0 +1,29 @@ +================================================================================ +meshcore-open +https://github.com/zjs81/meshcore-open +================================================================================ + +The Android USB Serial implementation (MeshMapperUsbService.kt) is based on +the USB host code from meshcore-open. + +MIT License + +Copyright (c) 2025 zjs81 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/analysis_options.yaml b/analysis_options.yaml index 9fd3c2c4..3d54c641 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -18,3 +18,7 @@ linter: analyzer: errors: invalid_annotation_target: ignore + exclude: + # Vendored, patched copy of maplibre_gl (see pubspec.yaml dependency_overrides). + # Analyze it under its own options, not ours. + - third_party/** diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 34689049..bf900a17 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -42,7 +42,7 @@ android { applicationId = "net.meshmapper.app" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + minSdk = flutter.minSdkVersion // MapLibre GL requires 23+ targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName @@ -60,6 +60,7 @@ android { buildTypes { release { signingConfig = signingConfigs.getByName("release") + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } } @@ -71,4 +72,13 @@ flutter { dependencies { // Required for flutter_local_notifications core library desugaring coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") + + // MapLibre Android SDK — already pulled transitively by maplibre_gl, but + // declaring it explicitly gives MainActivity.kt compile-time access to + // OfflineManager for the tile cache MethodChannel handlers. Version must + // match maplibre_gl-0.25.0's transitive dep. + implementation("org.maplibre.gl:android-sdk:12.3.1") + + // Android for Cars App Library. + implementation("androidx.car.app:app:1.7.0") } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 00000000..e89a275a --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,7 @@ +# flutter_local_notifications uses Gson to serialize/deserialize scheduled +# notification data. R8 strips the generic signature from TypeToken +# subclasses, causing "TypeToken must be created with a type argument" at +# runtime when cancel() tries to load the notification cache. +-keep class com.google.gson.reflect.TypeToken { *; } +-keep class * extends com.google.gson.reflect.TypeToken +-keepattributes Signature diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 688df15e..0cde108a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -14,6 +14,18 @@ + + + + + + + + + + @@ -24,12 +36,15 @@ + + android:label="MeshMapper DEV" + android:name=".MeshMapperApplication" + android:icon="@mipmap/ic_launcher" + android:fullBackupContent="@xml/backup_rules" + android:dataExtractionRules="@xml/data_extraction_rules"> + + + + + + + @@ -58,10 +82,28 @@ android:name="flutterEmbedding" android:value="2" /> + + + + + + + + + + + diff --git a/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index a11d769a..5ea65be2 100644 --- a/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -15,11 +15,21 @@ public final class GeneratedPluginRegistrant { private static final String TAG = "GeneratedPluginRegistrant"; public static void registerWith(@NonNull FlutterEngine flutterEngine) { + try { + flutterEngine.getPlugins().add(new com.llfbandit.app_links.AppLinksPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin app_links, com.llfbandit.app_links.AppLinksPlugin", e); + } try { flutterEngine.getPlugins().add(new com.ryanheise.audio_session.AudioSessionPlugin()); } catch (Exception e) { Log.e(TAG, "Error registering plugin audio_session, com.ryanheise.audio_session.AudioSessionPlugin", e); } + try { + flutterEngine.getPlugins().add(new com.piccmaq.disk_space_plus.DiskSpacePlusPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin disk_space_plus, com.piccmaq.disk_space_plus.DiskSpacePlusPlugin", e); + } try { flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin()); } catch (Exception e) { @@ -35,6 +45,11 @@ public static void registerWith(@NonNull FlutterEngine flutterEngine) { } catch (Exception e) { Log.e(TAG, "Error registering plugin flutter_blue_plus_android, com.lib.flutter_blue_plus.FlutterBluePlusPlugin", e); } + try { + flutterEngine.getPlugins().add(new com.oguzhnatly.flutter_android_auto.FlutterAndroidAutoPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin flutter_carplay, com.oguzhnatly.flutter_android_auto.FlutterAndroidAutoPlugin", e); + } try { flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin()); } catch (Exception e) { @@ -45,6 +60,11 @@ public static void registerWith(@NonNull FlutterEngine flutterEngine) { } catch (Exception e) { Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e); } + try { + flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin flutter_secure_storage, com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin", e); + } try { flutterEngine.getPlugins().add(new com.baseflow.geolocator.GeolocatorPlugin()); } catch (Exception e) { @@ -55,6 +75,11 @@ public static void registerWith(@NonNull FlutterEngine flutterEngine) { } catch (Exception e) { Log.e(TAG, "Error registering plugin just_audio, com.ryanheise.just_audio.JustAudioPlugin", e); } + try { + flutterEngine.getPlugins().add(new org.maplibre.maplibregl.MapLibreMapsPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin maplibre_gl, org.maplibre.maplibregl.MapLibreMapsPlugin", e); + } try { flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin()); } catch (Exception e) { diff --git a/android/app/src/main/kotlin/net/meshmapper/app/CarMapCoverage.kt b/android/app/src/main/kotlin/net/meshmapper/app/CarMapCoverage.kt new file mode 100644 index 00000000..657594e3 --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/CarMapCoverage.kt @@ -0,0 +1,9 @@ +package net.meshmapper.app +data class CarMapCoverage( + val tileUrl: String, + val fillColorExpression: String, + val outlineColorExpression: String, + val opacity: Float, + val minZoom: Float, + val maxZoom: Float, +) diff --git a/android/app/src/main/kotlin/net/meshmapper/app/CarMapTimerBar.kt b/android/app/src/main/kotlin/net/meshmapper/app/CarMapTimerBar.kt new file mode 100644 index 00000000..660c850a --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/CarMapTimerBar.kt @@ -0,0 +1,48 @@ +package net.meshmapper.app + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.view.View + +/// A thin bar down the right edge of the car map, counting to the next ping. +class CarMapTimerBar(context: Context) : View(context) { + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.argb(60, 0, 0, 0) + } + + private var endsAtMs: Long = 0 + private var durationMs: Long = 0 + + /// Set the phase being counted down, or clear it. + /// Null means the phase is idle or disconnected. + fun setPhase(endsAtMs: Long?, durationMs: Long?, color: Int?) { + if (endsAtMs == null || durationMs == null || durationMs <= 0) { + this.endsAtMs = 0 + this.durationMs = 0 + visibility = GONE + return + } + this.endsAtMs = endsAtMs + this.durationMs = durationMs + paint.color = color ?: Color.WHITE + visibility = VISIBLE + invalidate() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + if (durationMs <= 0) return + + val remaining = endsAtMs - System.currentTimeMillis() + val fraction = (remaining.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f) + + canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), trackPaint) + canvas.drawRect(0f, 0f, width.toFloat(), height * fraction, paint) + + if (remaining > 0) postInvalidateOnAnimation() + } +} diff --git a/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt b/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt index e7ff4c7c..c6e10efc 100644 --- a/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt +++ b/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt @@ -1,5 +1,100 @@ package net.meshmapper.app +import android.content.Context import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import org.maplibre.android.offline.OfflineManager +import org.maplibre.android.offline.OfflineRegion +import org.maplibre.android.offline.OfflineRegionStatus +import java.io.File -class MainActivity : FlutterActivity() +class MainActivity : FlutterActivity() { + override fun provideFlutterEngine(context: Context): FlutterEngine = + MeshMapperEngine.obtain(context) + + override fun shouldDestroyEngineWithHost(): Boolean = false + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + // MapLibre tile cache management. Mirrors AppDelegate.swift's iOS + // implementation. Called from Dart's TileCacheService by the Offline + // Maps screen's Tile Cache card. + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "meshmapper/tile_cache") + .setMethodCallHandler { call, result -> + when (call.method) { + "getCacheSize" -> { + val dbFile = File(applicationContext.getDatabasePath("mbgl-offline.db").absolutePath) + result.success(if (dbFile.exists()) dbFile.length() else 0L) + } + "getRegionSizes" -> { + getRegionSizes(result) + } + "clearAmbientCache" -> { + OfflineManager.getInstance(applicationContext).clearAmbientCache( + object : OfflineManager.FileSourceCallback { + override fun onSuccess() = result.success(null) + override fun onError(message: String) = + result.error("clear_failed", message, null) + } + ) + } + "invalidateAmbientCache" -> { + OfflineManager.getInstance(applicationContext).invalidateAmbientCache( + object : OfflineManager.FileSourceCallback { + override fun onSuccess() = result.success(null) + override fun onError(message: String) = + result.error("invalidate_failed", message, null) + } + ) + } + else -> result.notImplemented() + } + } + } + + /// Per-region downloaded byte counts, keyed by OfflineRegion.getID() which + /// is the same long ID the maplibre_gl plugin returns to Dart. Reads each + /// region's completedResourceSize (the tile+resource byte total for that + /// region as tracked by the SDK). Responds on the main thread with a + /// Map over the platform channel. + private fun getRegionSizes(result: MethodChannel.Result) { + OfflineManager.getInstance(applicationContext).listOfflineRegions( + object : OfflineManager.ListOfflineRegionsCallback { + override fun onList(regions: Array?) { + val list = regions ?: emptyArray() + if (list.isEmpty()) { + result.success(HashMap()) + return + } + val sizes = HashMap() + var remaining = list.size + for (region in list) { + region.getStatus(object : OfflineRegion.OfflineRegionStatusCallback { + override fun onStatus(status: OfflineRegionStatus?) { + if (status != null) { + sizes[region.id] = status.completedResourceSize + } + remaining -= 1 + if (remaining == 0) { + result.success(sizes) + } + } + override fun onError(error: String?) { + remaining -= 1 + if (remaining == 0) { + result.success(sizes) + } + } + }) + } + } + override fun onError(error: String) { + result.error("region_sizes_failed", error, null) + } + } + ) + } + +} diff --git a/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperApplication.kt b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperApplication.kt new file mode 100644 index 00000000..09fa37f1 --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperApplication.kt @@ -0,0 +1,13 @@ +package net.meshmapper.app + +import android.app.Application +import com.oguzhnatly.flutter_android_auto.FAAEngineProvider +import com.oguzhnatly.flutter_android_auto.FAASurfaceProvider + +class MeshMapperApplication : Application() { + override fun onCreate() { + super.onCreate() + FAAEngineProvider.factory = { context -> MeshMapperEngine.obtain(context) } + FAASurfaceProvider.factory = { carContext -> MeshMapperCarMapChannel.create(carContext) } + } +} diff --git a/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperCarMap.kt b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperCarMap.kt new file mode 100644 index 00000000..1f92ef09 --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperCarMap.kt @@ -0,0 +1,510 @@ +package net.meshmapper.app + +import android.app.Presentation +import android.content.Context +import android.graphics.Rect +import android.hardware.display.DisplayManager +import android.hardware.display.VirtualDisplay +import android.os.Bundle +import android.util.Log +import android.view.Display +import android.view.Gravity +import android.view.Surface +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.car.app.CarContext +import androidx.car.app.SurfaceCallback +import androidx.car.app.SurfaceContainer +import org.maplibre.android.MapLibre +import org.maplibre.android.camera.CameraPosition +import org.maplibre.android.camera.CameraUpdateFactory +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.MapLibreMapOptions +import org.maplibre.android.maps.MapView +import org.maplibre.android.maps.Style +import org.maplibre.android.style.expressions.Expression +import org.maplibre.android.style.layers.FillLayer +import org.maplibre.android.style.layers.PropertyFactory +import org.maplibre.android.style.layers.SymbolLayer +import org.maplibre.android.style.sources.TileSet +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import org.maplibre.android.style.layers.CircleLayer +import org.maplibre.android.style.sources.GeoJsonSource +import org.maplibre.android.style.sources.VectorSource + +/// Renders MeshMapper's map onto the android auto display +class MeshMapperCarMap(private val carContext: CarContext) : SurfaceCallback { + + private companion object { + const val TAG = "MeshMapperCarMap" + + /// The car reports its own density; this is only the fallback when a + /// SurfaceContainer arrives without one. + const val FALLBACK_DPI = 160 + + const val DEFAULT_ZOOM = 14.0 + + const val COVERAGE_SOURCE_ID = "meshmapper-car-coverage" + const val COVERAGE_LAYER_ID = "meshmapper-car-coverage-layer" + + /// The layer name inside the vector tiles vector_tile.php emits. + const val COVERAGE_SOURCE_LAYER = "coverage" + + const val PINGS_SOURCE_ID = "meshmapper-car-pings" + const val PINGS_LAYER_ID = "meshmapper-car-pings-layer" + + /// Big enough to see at a glance from the driver's seat, small enough + /// that a dense run does not become one solid blob. + const val PING_RADIUS_DP = 12.0f + const val PING_STROKE_DP = 1.5f + const val PING_STROKE_OPACITY = 0.6f + + const val POSITION_SOURCE_ID = "meshmapper-car-position" + const val POSITION_LAYER_ID = "meshmapper-car-position-layer" + const val POSITION_IMAGE_ID = "meshmapper-car-position-icon" + } + + private var virtualDisplay: VirtualDisplay? = null + private var presentation: MapPresentation? = null + + private var pendingCamera: CameraPosition? = null + private var pendingStyleUrl: String? = null + private var appliedStyleUrl: String? = null + private var pendingCoverage: CarMapCoverage? = null + private var pendingPings: String? = null + private var pendingTimer: Triple? = null + private var pendingMarker: Bitmap? = null + private var pendingMarkerFacesHeading = true + private var pendingHeading: Double? = null + + // ---------------------------------------------------------------- surface + + override fun onSurfaceAvailable(container: SurfaceContainer) { + val surface = container.surface + if (surface == null || container.width <= 0 || container.height <= 0) { + Log.w(TAG, "Surface available with nothing to draw on") + return + } + tearDown() + + val dpi = if (container.dpi > 0) container.dpi else FALLBACK_DPI + val display = DisplayManager::class.java.let { + (carContext.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager) + .createVirtualDisplay( + "MeshMapperCarMap", + container.width, + container.height, + dpi, + surface, + // PRESENTATION marks it as a display we intend to show a presentation on. + // OWN_CONTENT_ONLY keeps the phone from being mirrored to the car screen. + DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION or + DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY, + ) + } + if (display == null) { + Log.e(TAG, "Could not create a virtual display for the car surface") + return + } + virtualDisplay = display + + presentation = MapPresentation(carContext, display.display).also { + it.show() + pendingMarker?.let { bmp -> + it.setPositionMarker(bmp, pendingMarkerFacesHeading) + } + it.applyPending(pendingStyleUrl, pendingCamera, pendingCoverage, pendingPings) + pendingCamera?.let { cam -> + it.setPosition(cam.target!!.latitude, cam.target!!.longitude, pendingHeading) + } + pendingTimer.let { t -> it.setTimer(t?.first, t?.second, t?.third) } + } + Log.i(TAG, "Car map attached (${container.width}x${container.height} @ ${dpi}dpi)") + } + + override fun onSurfaceDestroyed(container: SurfaceContainer) { + Log.i(TAG, "Car map detached") + tearDown() + } + + /// The region the host guarantees is available. + override fun onVisibleAreaChanged(visibleArea: Rect) { + val map = presentation?.map ?: return + val view = presentation?.mapView ?: return + if (visibleArea.isEmpty) return + + val rightInset = (view.width - visibleArea.right).coerceAtLeast(0) + map.setPadding( + visibleArea.left, + visibleArea.top, + rightInset, + (view.height - visibleArea.bottom).coerceAtLeast(0), + ) + presentation?.setTimerInsets( + right = rightInset, + top = visibleArea.top.coerceAtLeast(0), + bottom = (view.height - visibleArea.bottom).coerceAtLeast(0), + ) + } + + override fun onStableAreaChanged(stableArea: Rect) { + // NO-OP + } + + // --------------------------------------------------------------- gestures + + override fun onScroll(distanceX: Float, distanceY: Float) { + presentation?.map?.let { it.scrollBy(-distanceX, -distanceY) } + } + + override fun onScale(focusX: Float, focusY: Float, scaleFactor: Float) { + val map = presentation?.map ?: return + val zoom = map.cameraPosition.zoom + kotlin.math.log2(scaleFactor.toDouble()) + map.moveCamera(CameraUpdateFactory.zoomTo(zoom)) + } + + override fun onFling(velocityX: Float, velocityY: Float) { + // NO-OP + } + + // ------------------------------------------------------------------- data + + /// Called from Dart via [MeshMapperCarMapChannel]. + fun setCamera( + lat: Double, + lon: Double, + bearing: Double?, + heading: Double?, + zoom: Double?, + ) { + val position = CameraPosition.Builder() + .target(LatLng(lat, lon)) + .zoom(zoom ?: DEFAULT_ZOOM) + .apply { bearing?.let { bearing(it) } } + .build() + pendingCamera = position + pendingHeading = heading + presentation?.map?.moveCamera(CameraUpdateFactory.newCameraPosition(position)) + presentation?.setPosition(lat, lon, heading) + } + + fun setPositionMarker(png: ByteArray, facesHeading: Boolean) { + val bitmap = BitmapFactory.decodeByteArray(png, 0, png.size) ?: run { + Log.e(TAG, "Could not decode the position marker") + return + } + pendingMarker = bitmap + pendingMarkerFacesHeading = facesHeading + presentation?.setPositionMarker(bitmap, facesHeading) + } + + /// The style the phone map is currently using + fun setStyle(url: String) { + if (url == appliedStyleUrl) return + pendingStyleUrl = url + presentation?.setStyle(url) { appliedStyleUrl = url } + } + + /// The coverage overlay, the same vector tiles the phone map draws. + fun setCoverage(coverage: CarMapCoverage?) { + if (coverage == pendingCoverage) return + pendingCoverage = coverage + presentation?.setCoverage(coverage) + } + + /// The ping markers, a GeoJSON FeatureCollection built by Dart. + fun setPings(geoJson: String) { + if (geoJson == pendingPings) return + pendingPings = geoJson + presentation?.setPings(geoJson) + } + + /// The next-ping countdown. Null will clear it. + fun setTimer(endsAtMs: Long?, durationMs: Long?, color: Int?) { + pendingTimer = if (endsAtMs != null && durationMs != null) { + Triple(endsAtMs, durationMs, color) + } else { + null + } + presentation?.setTimer(endsAtMs, durationMs, color) + } + + private fun tearDown() { + presentation?.let { + runCatching { it.dismiss() } + } + presentation = null + virtualDisplay?.release() + virtualDisplay = null + } + + /// The window that actually lives on the car display. + private class MapPresentation( + context: Context, + display: Display, + ) : Presentation(context, display) { + + var mapView: MapView? = null + private set + var map: MapLibreMap? = null + private set + + private var styleUrl: String? = null + private var camera: CameraPosition? = null + private var onStyleLoaded: (() -> Unit)? = null + private var coverage: CarMapCoverage? = null + private var pings: String? = null + private var timerBar: CarMapTimerBar? = null + private var markerBitmap: Bitmap? = null + private var markerFacesHeading = true + private var position: Triple? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + MapLibre.getInstance(context) + + val options = MapLibreMapOptions.createFromAttributes(context) + .compassEnabled(false) + .logoEnabled(false) + .attributionEnabled(false) + .rotateGesturesEnabled(false) + .scrollGesturesEnabled(false) + .tiltGesturesEnabled(false) + .zoomGesturesEnabled(false) + + val view = MapView(context, options) + mapView = view + view.onCreate(savedInstanceState) + view.onStart() + view.onResume() + view.getMapAsync { ready -> + map = ready + camera?.let { ready.moveCamera(CameraUpdateFactory.newCameraPosition(it)) } + styleUrl?.let { url -> + val loaded = onStyleLoaded + ready.setStyle(styleBuilder(url)) { style -> + applyOverlays(style) + loaded?.invoke() + } + } + } + + val bar = CarMapTimerBar(context).apply { visibility = View.GONE } + timerBar = bar + + setContentView( + FrameLayout(context).apply { + layoutParams = ViewGroup.LayoutParams(MATCH, MATCH) + addView(view, FrameLayout.LayoutParams(MATCH, MATCH, Gravity.CENTER)) + addView( + bar, + FrameLayout.LayoutParams( + (TIMER_BAR_DP * context.resources.displayMetrics.density).toInt(), + MATCH, + Gravity.END, + ), + ) + }, + ) + } + + fun applyPending( + style: String?, + position: CameraPosition?, + pendingCoverage: CarMapCoverage?, + pendingPings: String?, + ) { + coverage = pendingCoverage + pings = pendingPings + position?.let { + camera = it + map?.moveCamera(CameraUpdateFactory.newCameraPosition(it)) + } + style?.let { setStyle(it, onStyleLoaded) } + } + + fun setStyle(url: String, onLoaded: (() -> Unit)? = null) { + styleUrl = url + onStyleLoaded = onLoaded + map?.setStyle(styleBuilder(url)) { style -> + applyOverlays(style) + onLoaded?.invoke() + } + } + + private fun styleBuilder(style: String): Style.Builder { + val trimmed = style.trimStart() + return if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + Style.Builder().fromJson(style) + } else { + Style.Builder().fromUri(style) + } + } + + fun setCoverage(next: CarMapCoverage?) { + coverage = next + map?.style?.let { applyOverlays(it) } + } + + fun setTimer(endsAtMs: Long?, durationMs: Long?, color: Int?) { + timerBar?.setPhase(endsAtMs, durationMs, color) + } + + /// Keep the bar inside the area the host is not drawing over. + fun setTimerInsets(right: Int, top: Int, bottom: Int) { + val bar = timerBar ?: return + val params = bar.layoutParams as? FrameLayout.LayoutParams ?: return + if (params.rightMargin == right && + params.topMargin == top && + params.bottomMargin == bottom + ) { + return + } + params.rightMargin = right + params.topMargin = top + params.bottomMargin = bottom + bar.layoutParams = params + } + + fun setPositionMarker(bitmap: Bitmap, facesHeading: Boolean) { + markerBitmap = bitmap + markerFacesHeading = facesHeading + map?.style?.let { applyOverlays(it) } + } + + fun setPosition(lat: Double, lon: Double, heading: Double?) { + position = Triple(lat, lon, heading) + val style = map?.style ?: return + val source = style.getSourceAs(POSITION_SOURCE_ID) + if (source == null) { + applyOverlays(style) + return + } + source.setGeoJson(positionGeoJson(lat, lon)) + style.getLayerAs(POSITION_LAYER_ID) + ?.setProperties(PropertyFactory.iconRotate(markerRotation(heading))) + } + + private fun markerRotation(heading: Double?): Float { + if (!markerFacesHeading || heading == null) return 0f + val bearing = map?.cameraPosition?.bearing ?: 0.0 + return ((heading - bearing).toFloat() % 360f + 360f) % 360f + } + + private fun positionGeoJson(lat: Double, lon: Double): String = + """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]}}""" + + fun setPings(next: String) { + pings = next + val source = map?.style?.getSourceAs(PINGS_SOURCE_ID) + if (source != null) source.setGeoJson(next) else map?.style?.let { applyOverlays(it) } + } + + private fun applyOverlays(style: Style) { + applyCoverage(style) + applyPings(style) + applyPositionMarker(style) + } + + private fun applyPositionMarker(style: Style) { + style.getLayer(POSITION_LAYER_ID)?.let { style.removeLayer(it) } + style.getSource(POSITION_SOURCE_ID)?.let { style.removeSource(it) } + + val bitmap = markerBitmap ?: return + val here = position ?: return + try { + style.addImage(POSITION_IMAGE_ID, bitmap) + style.addSource( + GeoJsonSource(POSITION_SOURCE_ID, positionGeoJson(here.first, here.second)), + ) + val layer = SymbolLayer(POSITION_LAYER_ID, POSITION_SOURCE_ID) + layer.setProperties( + PropertyFactory.iconImage(POSITION_IMAGE_ID), + PropertyFactory.iconRotate(markerRotation(here.third)), + PropertyFactory.iconAllowOverlap(true), + PropertyFactory.iconIgnorePlacement(true), + ) + style.addLayer(layer) + } catch (e: Exception) { + Log.e(TAG, "Could not apply the position marker", e) + } + } + + /// Ping markers, above the coverage. + private fun applyPings(style: Style) { + style.getLayer(PINGS_LAYER_ID)?.let { style.removeLayer(it) } + style.getSource(PINGS_SOURCE_ID)?.let { style.removeSource(it) } + + val data = pings ?: return + try { + style.addSource(GeoJsonSource(PINGS_SOURCE_ID, data)) + val layer = CircleLayer(PINGS_LAYER_ID, PINGS_SOURCE_ID) + layer.setProperties( + PropertyFactory.circleColor(Expression.get("color")), + PropertyFactory.circleRadius(PING_RADIUS_DP), + PropertyFactory.circleStrokeWidth(PING_STROKE_DP), + PropertyFactory.circleStrokeColor("#000000"), + PropertyFactory.circleStrokeOpacity(PING_STROKE_OPACITY), + ) + style.addLayer(layer) + } catch (e: Exception) { + Log.e(TAG, "Could not apply the ping markers", e) + } + } + + private fun applyCoverage(style: Style) { + style.getLayer(COVERAGE_LAYER_ID)?.let { style.removeLayer(it) } + style.getSource(COVERAGE_SOURCE_ID)?.let { style.removeSource(it) } + + val config = coverage ?: return + try { + style.addSource( + VectorSource( + COVERAGE_SOURCE_ID, + TileSet(TILEJSON_VERSION, config.tileUrl).apply { + minZoom = config.minZoom + maxZoom = config.maxZoom + }, + ), + ) + + val layer = FillLayer(COVERAGE_LAYER_ID, COVERAGE_SOURCE_ID) + .withSourceLayer(COVERAGE_SOURCE_LAYER) + layer.setProperties( + PropertyFactory.fillColor( + Expression.Converter.convert(config.fillColorExpression), + ), + PropertyFactory.fillOutlineColor( + Expression.Converter.convert(config.outlineColorExpression), + ), + PropertyFactory.fillOpacity(config.opacity), + ) + + style.addLayer(layer) + } catch (e: Exception) { + Log.e(TAG, "Could not apply the coverage overlay", e) + } + } + + override fun onStop() { + mapView?.let { + it.onPause() + it.onStop() + it.onDestroy() + } + mapView = null + map = null + super.onStop() + } + + private companion object { + const val MATCH = ViewGroup.LayoutParams.MATCH_PARENT + const val TILEJSON_VERSION = "2.2.0" + const val TIMER_BAR_DP = 8 + } + } +} diff --git a/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperCarMapChannel.kt b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperCarMapChannel.kt new file mode 100644 index 00000000..94f0e2de --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperCarMapChannel.kt @@ -0,0 +1,107 @@ +package net.meshmapper.app + +import androidx.car.app.CarContext +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel + +/// Dart's channel for the car map. +object MeshMapperCarMapChannel { + private const val CHANNEL = "meshmapper/car_map" + + @Volatile + private var renderer: MeshMapperCarMap? = null + + /// Installed into `FAASurfaceProvider.factory` by [MeshMapperApplication]. + fun create(carContext: CarContext): MeshMapperCarMap = + MeshMapperCarMap(carContext).also { renderer = it } + + fun register(engine: FlutterEngine) { + MethodChannel(engine.dartExecutor.binaryMessenger, CHANNEL) + .setMethodCallHandler { call, result -> + val map = renderer + when (call.method) { + // True if there is a car screen to draw on. + "isAttached" -> result.success(map != null) + + "setCamera" -> { + val lat = call.argument("lat") + val lon = call.argument("lon") + if (lat == null || lon == null) { + result.error("bad_args", "lat and lon are required", null) + } else { + map?.setCamera( + lat, + lon, + call.argument("bearing"), + call.argument("heading"), + call.argument("zoom"), + ) + result.success(map != null) + } + } + + "setStyle" -> { + val url = call.argument("url") + if (url.isNullOrEmpty()) { + result.error("bad_args", "url is required", null) + } else { + map?.setStyle(url) + result.success(map != null) + } + } + + "setCoverage" -> { + val tileUrl = call.argument("tileUrl") + map?.setCoverage( + if (tileUrl.isNullOrEmpty()) { + null + } else { + CarMapCoverage( + tileUrl = tileUrl, + fillColorExpression = + call.argument("fillColor") ?: "", + outlineColorExpression = + call.argument("outlineColor") ?: "", + opacity = + (call.argument("opacity") ?: 0.7).toFloat(), + minZoom = + (call.argument("minZoom") ?: 7.0).toFloat(), + maxZoom = + (call.argument("maxZoom") ?: 14.0).toFloat(), + ) + }, + ) + result.success(map != null) + } + + "setPositionMarker" -> { + val png = call.argument("png") + if (png == null) { + result.error("bad_args", "png is required", null) + } else { + map?.setPositionMarker( + png, + call.argument("facesHeading") ?: true, + ) + result.success(map != null) + } + } + + "setPings" -> { + map?.setPings(call.argument("geoJson") ?: "") + result.success(map != null) + } + + "setTimer" -> { + val endsAt = (call.argument("endsAtMs"))?.toLong() + val duration = (call.argument("durationMs"))?.toLong() + val color = (call.argument("color"))?.toInt() + map?.setTimer(endsAt, duration, color) + result.success(map != null) + } + + else -> result.notImplemented() + } + } + } +} diff --git a/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperEngine.kt b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperEngine.kt new file mode 100644 index 00000000..cbcc019c --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperEngine.kt @@ -0,0 +1,30 @@ +package net.meshmapper.app + +import android.content.Context +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.FlutterEngineCache +import io.flutter.embedding.engine.dart.DartExecutor + + +object MeshMapperEngine { + + const val ENGINE_ID = "meshmapper_main" + + private var usbService: MeshMapperUsbService? = null + + @Synchronized + fun obtain(context: Context): FlutterEngine { + FlutterEngineCache.getInstance().get(ENGINE_ID)?.let { return it } + val app = context.applicationContext + val engine = FlutterEngine(app) + registerAppScopedChannels(app, engine) + engine.dartExecutor.executeDartEntrypoint(DartExecutor.DartEntrypoint.createDefault()) + FlutterEngineCache.getInstance().put(ENGINE_ID, engine) + return engine + } + + private fun registerAppScopedChannels(app: Context, engine: FlutterEngine) { + usbService = MeshMapperUsbService(app).also { it.configureFlutterEngine(engine) } + MeshMapperCarMapChannel.register(engine) + } +} diff --git a/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperUsbService.kt b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperUsbService.kt new file mode 100644 index 00000000..d99fb03b --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperUsbService.kt @@ -0,0 +1,556 @@ +// Android USB Serial implementation based on meshcore-open +// https://github.com/zjs81/meshcore-open +// Copyright (c) zjs81, MIT License — see THIRD_PARTY_LICENSES + +package net.meshmapper.app + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import android.hardware.usb.UsbManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicLong + +class MeshMapperUsbService( + private val context: Context, +) { + private companion object { + const val USB_RECIPIENT_INTERFACE = 0x01 + const val METHOD_CHANNEL = "net.meshmapper.app/usb_serial" + const val EVENT_CHANNEL = "net.meshmapper.app/usb_serial/data" + const val USB_PERMISSION_ACTION = "net.meshmapper.app.USB_PERMISSION" + } + + private val usbManager by lazy { + context.getSystemService(Context.USB_SERVICE) as UsbManager + } + private val mainHandler = Handler(Looper.getMainLooper()) + private val usbIoExecutor: ExecutorService = Executors.newSingleThreadExecutor() + + @Volatile private var eventSink: EventChannel.EventSink? = null + @Volatile private var usbConnection: UsbDeviceConnection? = null + @Volatile private var usbInEndpoint: UsbEndpoint? = null + @Volatile private var usbOutEndpoint: UsbEndpoint? = null + @Volatile private var controlInterface: UsbInterface? = null + @Volatile private var dataInterface: UsbInterface? = null + private var readThread: Thread? = null + @Volatile private var isReading = false + @Volatile private var connectedDeviceName: String? = null + + private val readAttempts = AtomicLong(0) + private val bytesReceived = AtomicLong(0) + private val eventsPosted = AtomicLong(0) + private val sinkNullCount = AtomicLong(0) + + private var pendingConnectResult: MethodChannel.Result? = null + private var pendingConnectPortName: String? = null + private var pendingConnectBaudRate: Int = 115200 + + private data class PortConfig( + val controlInterface: UsbInterface?, + val dataInterface: UsbInterface, + val inEndpoint: UsbEndpoint, + val outEndpoint: UsbEndpoint, + ) + + private val permissionReceiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + UsbManager.ACTION_USB_DEVICE_DETACHED -> { + handleUsbDetached(intent) + return + } + USB_PERMISSION_ACTION -> Unit + else -> return + } + + val result = pendingConnectResult + val portName = pendingConnectPortName + pendingConnectResult = null + pendingConnectPortName = null + + if (result == null || portName == null) return + + val device = findUsbDevice(portName) + if (device == null) { + result.error("usb_device_missing", "USB device not found after permission grant", null) + return + } + + val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) + if (!granted || !usbManager.hasPermission(device)) { + result.error("usb_permission_denied", "USB permission was denied", null) + return + } + + openUsbDevice(device, pendingConnectBaudRate, result) + } + } + + fun configureFlutterEngine(flutterEngine: FlutterEngine) { + registerUsbPermissionReceiver() + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL) + .setMethodCallHandler { call, result -> + when (call.method) { + "listDevices" -> result.success(listUsbDevices()) + "connect" -> handleConnect(call, result) + "write" -> handleWrite(call, result) + "readDiagnostics" -> result.success(mapOf( + "readAttempts" to readAttempts.get(), + "bytesReceived" to bytesReceived.get(), + "eventsPosted" to eventsPosted.get(), + "sinkNullCount" to sinkNullCount.get(), + "isReading" to isReading, + "threadAlive" to (readThread?.isAlive == true), + "sinkSet" to (eventSink != null), + )) + "disconnect" -> { + scheduleCloseUsbConnection { + result.success(null) + } + } + else -> result.notImplemented() + } + } + + EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL) + .setStreamHandler( + object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink) { + eventSink = events + } + override fun onCancel(arguments: Any?) { + eventSink = null + } + }, + ) + } + + fun dispose() { + closeUsbConnection() + usbIoExecutor.shutdownNow() + try { + context.unregisterReceiver(permissionReceiver) + } catch (_: IllegalArgumentException) {} + } + + private fun registerUsbPermissionReceiver() { + val filter = IntentFilter().apply { + addAction(USB_PERMISSION_ACTION) + addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(permissionReceiver, filter, Context.RECEIVER_EXPORTED) + } else { + @Suppress("DEPRECATION") + context.registerReceiver(permissionReceiver, filter) + } + } + + private fun listUsbDevices(): List> { + return usbManager.deviceList.values.map { device -> + val serial = try { device.serialNumber } catch (_: SecurityException) { null } + mapOf( + "deviceName" to device.deviceName, + "productName" to (device.productName ?: "USB Serial Device"), + "vid" to device.vendorId, + "pid" to device.productId, + "serial" to serial, + ) + } + } + + private fun handleConnect(call: MethodCall, result: MethodChannel.Result) { + val deviceName = call.argument("deviceName") + val baudRate = call.argument("baudRate") ?: 115200 + if (deviceName.isNullOrBlank()) { + result.error("usb_invalid_port", "Device name is required", null) + return + } + + val device = findUsbDevice(deviceName) + if (device == null) { + result.error("usb_device_missing", "USB device '$deviceName' not found", null) + return + } + + if (usbManager.hasPermission(device)) { + openUsbDevice(device, baudRate, result) + return + } + + if (pendingConnectResult != null) { + result.error("usb_busy", "Another USB permission request is pending", null) + return + } + + pendingConnectResult = result + pendingConnectPortName = deviceName + pendingConnectBaudRate = baudRate + + val permissionIntent = PendingIntent.getBroadcast( + context, + 0, + Intent(USB_PERMISSION_ACTION).setPackage(context.packageName), + pendingIntentFlags(), + ) + usbManager.requestPermission(device, permissionIntent) + } + + private fun handleWrite(call: MethodCall, result: MethodChannel.Result) { + val data = call.argument("data") + val connection = usbConnection + val endpoint = usbOutEndpoint + if (data == null) { + result.error("usb_invalid_data", "Write data is required", null) + return + } + if (connection == null || endpoint == null) { + result.error("usb_not_connected", "No USB device connected", null) + return + } + + usbIoExecutor.execute { + try { + writeToDevice(data) + mainHandler.post { result.success(null) } + } catch (error: Exception) { + mainHandler.post { + result.error("usb_write_failed", error.message, null) + } + } + } + } + + private fun findUsbDevice(deviceName: String): UsbDevice? { + return usbManager.deviceList.values.firstOrNull { it.deviceName == deviceName } + } + + private fun openUsbDevice( + device: UsbDevice, + baudRate: Int, + result: MethodChannel.Result, + ) { + usbIoExecutor.execute { + try { + closeUsbConnection() + + val config = resolvePortConfig(device) + if (config == null) { + mainHandler.post { + result.error("usb_no_endpoints", "No compatible USB endpoints found on device", null) + } + return@execute + } + + val connection = usbManager.openDevice(device) + if (connection == null) { + mainHandler.post { + result.error("usb_open_failed", "Failed to open USB device connection", null) + } + return@execute + } + + if (!connection.claimInterface(config.dataInterface, true)) { + connection.close() + mainHandler.post { + result.error("usb_open_failed", "Failed to claim USB data interface", null) + } + return@execute + } + + if (config.controlInterface != null && + config.controlInterface.id != config.dataInterface.id && + !connection.claimInterface(config.controlInterface, true) + ) { + connection.releaseInterface(config.dataInterface) + connection.close() + mainHandler.post { + result.error("usb_open_failed", "Failed to claim USB control interface", null) + } + return@execute + } + + usbConnection = connection + usbInEndpoint = config.inEndpoint + usbOutEndpoint = config.outEndpoint + controlInterface = config.controlInterface + dataInterface = config.dataInterface + + configureDevice(connection, config, baudRate) + + connectedDeviceName = device.deviceName + startReadLoop() + + mainHandler.post { + result.success(mapOf( + "interfaceCount" to device.interfaceCount, + "controlFound" to (config.controlInterface != null), + "controlId" to config.controlInterface?.id, + "controlClass" to config.controlInterface?.interfaceClass, + "dataId" to config.dataInterface.id, + "dataClass" to config.dataInterface.interfaceClass, + "inMaxPacket" to config.inEndpoint.maxPacketSize, + "outMaxPacket" to config.outEndpoint.maxPacketSize, + )) + } + } catch (error: Exception) { + closeUsbConnection() + mainHandler.post { + result.error("usb_connect_failed", error.message, null) + } + } + } + } + + private fun resolvePortConfig(device: UsbDevice): PortConfig? { + var preferredDataInterface: UsbInterface? = null + var preferredInEndpoint: UsbEndpoint? = null + var preferredOutEndpoint: UsbEndpoint? = null + var fallbackDataInterface: UsbInterface? = null + var fallbackInEndpoint: UsbEndpoint? = null + var fallbackOutEndpoint: UsbEndpoint? = null + var preferredControlInterface: UsbInterface? = null + + for (i in 0 until device.interfaceCount) { + val usbInterface = device.getInterface(i) + var inEp: UsbEndpoint? = null + var outEp: UsbEndpoint? = null + + for (j in 0 until usbInterface.endpointCount) { + val endpoint = usbInterface.getEndpoint(j) + if (endpoint.type != UsbConstants.USB_ENDPOINT_XFER_BULK) continue + when (endpoint.direction) { + UsbConstants.USB_DIR_IN -> if (inEp == null) inEp = endpoint + UsbConstants.USB_DIR_OUT -> if (outEp == null) outEp = endpoint + } + } + + val hasDataPair = inEp != null && outEp != null + when { + usbInterface.interfaceClass == UsbConstants.USB_CLASS_COMM && + preferredControlInterface == null -> { + preferredControlInterface = usbInterface + } + hasDataPair && + usbInterface.interfaceClass == UsbConstants.USB_CLASS_CDC_DATA -> { + preferredDataInterface = usbInterface + preferredInEndpoint = inEp + preferredOutEndpoint = outEp + } + hasDataPair && fallbackDataInterface == null -> { + fallbackDataInterface = usbInterface + fallbackInEndpoint = inEp + fallbackOutEndpoint = outEp + } + } + } + + val dataIface = preferredDataInterface ?: fallbackDataInterface ?: return null + val inEndpoint = preferredInEndpoint ?: fallbackInEndpoint ?: return null + val outEndpoint = preferredOutEndpoint ?: fallbackOutEndpoint ?: return null + return PortConfig(preferredControlInterface, dataIface, inEndpoint, outEndpoint) + } + + private fun configureDevice( + connection: UsbDeviceConnection, + config: PortConfig, + baudRate: Int, + ) { + val control = config.controlInterface ?: return + + val lineCoding = byteArrayOf( + (baudRate and 0xFF).toByte(), + ((baudRate shr 8) and 0xFF).toByte(), + ((baudRate shr 16) and 0xFF).toByte(), + ((baudRate shr 24) and 0xFF).toByte(), + 0, // stop bits: 1 + 0, // parity: none + 8, // data bits + ) + + val lineCodingResult = connection.controlTransfer( + UsbConstants.USB_DIR_OUT or UsbConstants.USB_TYPE_CLASS or USB_RECIPIENT_INTERFACE, + 0x20, // SET_LINE_CODING + 0, + control.id, + lineCoding, + lineCoding.size, + 1000, + ) + if (lineCodingResult < 0) { + throw IllegalStateException("Failed to configure USB line coding") + } + + val controlLineResult = connection.controlTransfer( + UsbConstants.USB_DIR_OUT or UsbConstants.USB_TYPE_CLASS or USB_RECIPIENT_INTERFACE, + 0x22, // SET_CONTROL_LINE_STATE + 0x0001, // DTR on, RTS off + control.id, + null, + 0, + 1000, + ) + if (controlLineResult < 0) { + throw IllegalStateException("Failed to configure USB control line state") + } + } + + private fun startReadLoop() { + val connection = usbConnection ?: return + val endpoint = usbInEndpoint ?: return + + isReading = true + readAttempts.set(0) + bytesReceived.set(0) + eventsPosted.set(0) + sinkNullCount.set(0) + + readThread = Thread({ + val packetSize = endpoint.maxPacketSize.coerceAtLeast(64) + val buffer = ByteArray(packetSize * 4) + try { + while (isReading) { + readAttempts.incrementAndGet() + val bytesRead = connection.bulkTransfer(endpoint, buffer, buffer.size, 250) + if (!isReading) break + if (bytesRead <= 0) continue + bytesReceived.addAndGet(bytesRead.toLong()) + val packet = buffer.copyOf(bytesRead) + mainHandler.post { + val sink = eventSink + if (sink != null) { + sink.success(packet) + eventsPosted.incrementAndGet() + } else { + sinkNullCount.incrementAndGet() + } + } + } + } catch (error: Exception) { + if (isReading) { + mainHandler.post { + eventSink?.error("usb_io_error", error.message ?: "USB serial I/O error", null) + } + scheduleCloseUsbConnection() + } + } + }, "MeshMapperUsbRead").also { thread -> + thread.isDaemon = true + thread.start() + } + } + + private fun writeToDevice(data: ByteArray) { + val connection = usbConnection ?: throw IllegalStateException("USB connection missing") + val endpoint = usbOutEndpoint ?: throw IllegalStateException("USB output endpoint missing") + var offset = 0 + val maxPacketSize = endpoint.maxPacketSize.coerceAtLeast(64) + while (offset < data.size) { + val chunkSize = minOf(maxPacketSize, data.size - offset) + val chunk = data.copyOfRange(offset, offset + chunkSize) + val bytesWritten = connection.bulkTransfer(endpoint, chunk, chunkSize, 1000) + if (bytesWritten < 0) { + throw IllegalStateException("USB write error: bulkTransfer returned $bytesWritten") + } + offset += bytesWritten + } + } + + private fun scheduleCloseUsbConnection(onComplete: (() -> Unit)? = null) { + usbIoExecutor.execute { + closeUsbConnection() + if (onComplete != null) { + mainHandler.post(onComplete) + } + } + } + + @Synchronized + private fun closeUsbConnection() { + isReading = false + readThread?.interrupt() + if (readThread != null && readThread !== Thread.currentThread()) { + try { + readThread?.join(300) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + readThread = null + + val connection = usbConnection + val claimedControl = controlInterface + val claimedData = dataInterface + + usbInEndpoint = null + usbOutEndpoint = null + controlInterface = null + dataInterface = null + usbConnection = null + + if (connection != null) { + if (claimedControl != null) { + try { connection.releaseInterface(claimedControl) } catch (_: Exception) {} + } + if (claimedData != null && claimedData.id != claimedControl?.id) { + try { connection.releaseInterface(claimedData) } catch (_: Exception) {} + } + try { connection.close() } catch (_: Exception) {} + } + connectedDeviceName = null + } + + private fun handleUsbDetached(intent: Intent) { + val detachedDevice = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) + } + + val detachedName = detachedDevice?.deviceName ?: return + + if (pendingConnectPortName == detachedName) { + pendingConnectResult?.error( + "usb_device_detached", + "USB device was removed before the connection completed", + null, + ) + pendingConnectResult = null + pendingConnectPortName = null + } + + if (connectedDeviceName == detachedName) { + scheduleCloseUsbConnection { + eventSink?.error( + "usb_device_detached", + "USB device was disconnected", + null, + ) + } + } + } + + private fun pendingIntentFlags(): Int { + return PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + } +} diff --git a/android/app/src/main/res/xml/automotive_app_desc.xml b/android/app/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 00000000..1a5248e1 --- /dev/null +++ b/android/app/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 00000000..f7ceff18 --- /dev/null +++ b/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 00000000..c2c24978 --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/android/gradle.properties b/android/gradle.properties index fbee1d8c..d5da7278 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,2 +1,6 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=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 diff --git a/assets/car_start.png b/assets/car_start.png new file mode 100644 index 00000000..3e410428 Binary files /dev/null and b/assets/car_start.png differ diff --git a/assets/car_stop.png b/assets/car_stop.png new file mode 100644 index 00000000..3a6bcc77 Binary files /dev/null and b/assets/car_stop.png differ diff --git a/assets/device-models.json b/assets/device-models.json index 0da3f2e6..22f5a6b2 100644 --- a/assets/device-models.json +++ b/assets/device-models.json @@ -243,6 +243,14 @@ "txPower": 22, "notes": "Seeed Tracker T1000" }, + { + "manufacturer": "Seeed Xiao-nrf52", + "shortName": "Seeed Xiao nRF52", + "power": 0.3, + "platform": "nrf52", + "txPower": 22, + "notes": "Seeed Xiao nRF52840 bare board, no PA amplifier" + }, { "manufacturer": "Xiao C3", "shortName": "Xiao C3", @@ -291,6 +299,14 @@ "txPower": 22, "notes": "Nano G2 Ultra" }, + { + "manufacturer": "pyMC-Repeater-Companion", + "shortName": "pyMC Repeater Companion", + "power": 1.0, + "platform": "PyMC", + "txPower": 30, + "notes": "pyMC Repeater Companion" + }, { "manufacturer": "Station G2", "shortName": "Station G2", diff --git a/bin/test_message.dart b/bin/test_message.dart index f111341e..48ab857b 100644 --- a/bin/test_message.dart +++ b/bin/test_message.dart @@ -107,8 +107,22 @@ class PayloadType { class CryptoService { /// Fixed key for "Public" channel static final Uint8List publicChannelFixedKey = Uint8List.fromList([ - 0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a, - 0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72, + 0x8b, + 0x33, + 0x87, + 0xe9, + 0xc5, + 0xcd, + 0xea, + 0x6a, + 0xc9, + 0xe5, + 0xed, + 0xba, + 0xa1, + 0x15, + 0xcd, + 0x72, ]); /// Derive a 16-byte channel key from a hashtag channel name using SHA-256 @@ -228,8 +242,10 @@ class PacketMetadata { final int header = raw[0]; final int routeType = header & PacketHeader.routeMask; - final int payloadType = (header >> PacketHeader.typeShift) & PacketHeader.typeMask; - final int protocolVersion = (header >> PacketHeader.verShift) & PacketHeader.verMask; + final int payloadType = + (header >> PacketHeader.typeShift) & PacketHeader.typeMask; + final int protocolVersion = + (header >> PacketHeader.verShift) & PacketHeader.verMask; // Calculate offset for Path Length based on route type int pathLengthOffset = 1; @@ -427,9 +443,12 @@ void main(List arguments) { // Print packet metadata print('PACKET METADATA'); - print(' Header: 0x${metadata.header.toRadixString(16).padLeft(2, '0').toUpperCase()}'); - print(' Route Type: ${RouteType.getName(metadata.routeType)} (0x${metadata.routeType.toRadixString(16).padLeft(2, '0')})'); - print(' Payload Type: ${PayloadType.getName(metadata.payloadType)} (0x${metadata.payloadType.toRadixString(16).padLeft(2, '0')})'); + print( + ' Header: 0x${metadata.header.toRadixString(16).padLeft(2, '0').toUpperCase()}'); + print( + ' Route Type: ${RouteType.getName(metadata.routeType)} (0x${metadata.routeType.toRadixString(16).padLeft(2, '0')})'); + print( + ' Payload Type: ${PayloadType.getName(metadata.payloadType)} (0x${metadata.payloadType.toRadixString(16).padLeft(2, '0')})'); print(' Protocol Version: ${metadata.protocolVersion}'); print(' Path Length: ${metadata.pathLength} bytes'); @@ -444,10 +463,12 @@ void main(List arguments) { print(' Path: (empty)'); } - print(' Encrypted Payload: ${formatHex(metadata.encryptedPayload)} (${metadata.encryptedPayload.length} bytes)'); + print( + ' Encrypted Payload: ${formatHex(metadata.encryptedPayload)} (${metadata.encryptedPayload.length} bytes)'); if (metadata.channelHash != null) { - print(' Channel Hash: 0x${metadata.channelHash!.toRadixString(16).padLeft(2, '0').toUpperCase()}'); + print( + ' Channel Hash: 0x${metadata.channelHash!.toRadixString(16).padLeft(2, '0').toUpperCase()}'); } print(''); @@ -514,7 +535,8 @@ void main(List arguments) { print(''); print(' Known channel hashes:'); for (final entry in channels.entries) { - print(' 0x${entry.key.toRadixString(16).padLeft(2, '0').toUpperCase()} -> ${entry.value.channelName}'); + print( + ' 0x${entry.key.toRadixString(16).padLeft(2, '0').toUpperCase()} -> ${entry.value.channelName}'); } printValidationResults(steps, false, 'Unknown channel hash'); return; diff --git a/bin/test_offline_upload.dart b/bin/test_offline_upload.dart new file mode 100644 index 00000000..8a47c9e5 --- /dev/null +++ b/bin/test_offline_upload.dart @@ -0,0 +1,975 @@ +/// MeshMapper Offline Upload Diagnostic Script +/// +/// Reproduces server-side session invalidation during offline batch uploads. +/// Runs four scenarios to determine if the server has an item count limit, +/// rate limit, zone mismatch issue, or other constraint. +/// +/// Usage: +/// dart run bin/test_offline_upload.dart --key= --pubkey= [options] +/// +/// Required: +/// --key= MeshMapper API key +/// --pubkey= Registered device public key (hex) +/// +/// Optional: +/// --lat= Latitude for auth (default: 45.3215) +/// --lon= Longitude for auth (default: -75.6693) +/// --data-lat= Latitude for ping data (default: same as --lat) +/// --data-lon= Longitude for ping data (default: same as --lon) +/// --who= Device name (default: DIAG-TEST) +/// --scenario=<1|2|3|4|all> Run specific scenario (default: all) +/// --contact-uri= Signed contact URI (for unregistered devices) +/// +/// Examples: +/// dart run bin/test_offline_upload.dart --key=abc123 --pubkey=deadbeef... +/// dart run bin/test_offline_upload.dart --key=abc123 --pubkey=deadbeef... --scenario=1 +library; + +// ignore_for_file: avoid_print + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +const String baseUrl = 'https://meshmapper.net'; +const String authEndpoint = '$baseUrl/wardrive-api.php/auth'; +const String wardriveEndpoint = '$baseUrl/wardrive-api.php/wardrive'; +final String testVersion = + 'APP-${DateTime.now().millisecondsSinceEpoch ~/ 1000}'; +const String testModel = 'Offline Upload'; + +// ============================================================================ +// GLOBAL STATE (for Ctrl+C cleanup) +// ============================================================================ + +String? _activeSessionId; +String? _activeApiKey; +String? _activePubkey; +http.Client? _activeClient; + +// ============================================================================ +// DATA GENERATION +// ============================================================================ + +List> generateTestItems( + int count, { + double baseLat = 45.3215, + double baseLon = -75.6693, +}) { + final baseTimestamp = (DateTime.now() + .subtract(const Duration(hours: 24)) + .millisecondsSinceEpoch ~/ + 1000); + final items = >[]; + + for (var i = 0; i < count; i++) { + final timestamp = baseTimestamp + (i * 10); + final lat = baseLat + (i * 0.00001); + final lon = baseLon + (i * 0.00001); + + if (i.isEven) { + items.add({ + 'type': 'RX', + 'lat': lat, + 'lon': lon, + 'noisefloor': -100, + 'heard_repeats': 'ff(0.0)', + 'timestamp': timestamp, + 'external_antenna': true, + 'power': '0.3w', + }); + } else { + items.add({ + 'type': 'DISC', + 'lat': lat, + 'lon': lon, + 'noisefloor': -100, + 'repeater_id': 'ff', + 'node_type': 'REPEATER', + 'local_snr': 0.0, + 'local_rssi': -100, + 'remote_snr': 0.0, + 'public_key': + '0000000000000000000000000000000000000000000000000000000000000000', + 'timestamp': timestamp, + 'external_antenna': true, + 'power': '0.3w', + }); + } + } + return items; +} + +// ============================================================================ +// API HELPERS +// ============================================================================ + +Future?> authenticate({ + required http.Client client, + required String apiKey, + required String pubkey, + required double lat, + required double lon, + required String who, + String? contactUri, +}) async { + final payload = { + 'key': apiKey, + 'reason': 'connect', + 'offline_mode': true, + 'who': who, + 'ver': testVersion, + 'power': '0.3w', + 'model': testModel, + 'coords': { + 'lat': lat, + 'lng': lon, + 'accuracy_m': 5.0, + 'timestamp': DateTime.now().millisecondsSinceEpoch ~/ 1000, + }, + }; + + if (contactUri != null) { + payload['contact_uri'] = contactUri; + } else { + payload['public_key'] = pubkey; + } + + try { + final stopwatch = Stopwatch()..start(); + final response = await client + .post( + Uri.parse(authEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 10)); + stopwatch.stop(); + + final data = json.decode(response.body) as Map; + + if (response.statusCode != 200) { + print(' Auth HTTP ${response.statusCode}: ${response.body}'); + } + + return { + ...data, + '_http_status': response.statusCode, + '_elapsed_ms': stopwatch.elapsedMilliseconds, + }; + } catch (e) { + print(' Auth exception: $e'); + return null; + } +} + +Future?> registerDevice({ + required http.Client client, + required String apiKey, + required String contactUri, + required double lat, + required double lon, + required String who, +}) async { + return authenticate( + client: client, + apiKey: apiKey, + pubkey: '', + lat: lat, + lon: lon, + who: who, + contactUri: contactUri, + ); +} + +Future uploadBatch({ + required http.Client client, + required String apiKey, + required String sessionId, + required List> items, + required int batchNum, + required int cumulativeItems, +}) async { + final stopwatch = Stopwatch()..start(); + + try { + final payload = { + 'key': apiKey, + 'session_id': sessionId, + 'data': items, + }; + + final response = await client + .post( + Uri.parse(wardriveEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); + + stopwatch.stop(); + + final data = json.decode(response.body) as Map; + + return BatchResult( + batchNumber: batchNum, + itemCount: items.length, + cumulativeItems: cumulativeItems, + success: data['success'] == true, + httpStatus: response.statusCode, + reason: data['reason'] as String?, + message: data['message'] as String?, + expiresAt: data['expires_at'] as int?, + responseTime: stopwatch.elapsed, + ); + } catch (e) { + stopwatch.stop(); + return BatchResult( + batchNumber: batchNum, + itemCount: items.length, + cumulativeItems: cumulativeItems, + success: false, + httpStatus: 0, + reason: 'exception', + message: '$e', + expiresAt: null, + responseTime: stopwatch.elapsed, + ); + } +} + +Future disconnectSession({ + required http.Client client, + required String apiKey, + required String pubkey, + required String sessionId, +}) async { + try { + final payload = { + 'key': apiKey, + 'reason': 'disconnect', + 'public_key': pubkey, + 'session_id': sessionId, + }; + + await client + .post( + Uri.parse(authEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 10)); + } catch (_) { + // Best effort + } +} + +// ============================================================================ +// RESULT TYPES +// ============================================================================ + +class BatchResult { + final int batchNumber; + final int itemCount; + final int cumulativeItems; + final bool success; + final int httpStatus; + final String? reason; + final String? message; + final int? expiresAt; + final Duration responseTime; + + BatchResult({ + required this.batchNumber, + required this.itemCount, + required this.cumulativeItems, + required this.success, + required this.httpStatus, + this.reason, + this.message, + this.expiresAt, + required this.responseTime, + }); +} + +class ScenarioResult { + final String name; + final String description; + final int batchSize; + final Duration interBatchDelay; + final List batches; + final int? failedAtBatch; + final int? failedAtCumulativeItems; + final String? failureReason; + final bool authFailed; + + ScenarioResult({ + required this.name, + required this.description, + required this.batchSize, + required this.interBatchDelay, + required this.batches, + this.failedAtBatch, + this.failedAtCumulativeItems, + this.failureReason, + this.authFailed = false, + }); + + bool get passed => failedAtBatch == null && !authFailed; + int get totalUploaded => + batches.where((b) => b.success).fold(0, (sum, b) => sum + b.itemCount); +} + +// ============================================================================ +// SCENARIO RUNNER +// ============================================================================ + +Future runScenario({ + required http.Client client, + required String apiKey, + required String pubkey, + required double lat, + required double lon, + required String who, + required String name, + required String description, + required int batchSize, + required int maxBatches, + required Duration interBatchDelay, + String? contactUri, + double? dataLat, + double? dataLon, +}) async { + // Authenticate + stdout.write(' Authenticating...'); + final authResult = await authenticate( + client: client, + apiKey: apiKey, + pubkey: pubkey, + lat: lat, + lon: lon, + who: who, + contactUri: contactUri, + ); + + if (authResult == null || authResult['success'] != true) { + final reason = authResult?['reason'] as String? ?? 'network error'; + + // Try registration if unknown_device and contactUri available + if (reason == 'unknown_device' && contactUri != null) { + stdout.write(' unknown device, trying registration...'); + final regResult = await registerDevice( + client: client, + apiKey: apiKey, + contactUri: contactUri, + lat: lat, + lon: lon, + who: who, + ); + if (regResult == null || regResult['success'] != true) { + print(' FAILED (registration: ${regResult?['reason'] ?? 'error'})'); + return ScenarioResult( + name: name, + description: description, + batchSize: batchSize, + interBatchDelay: interBatchDelay, + batches: [], + failureReason: 'Registration failed: ${regResult?['reason']}', + authFailed: true, + ); + } + // Use registration session + final sessionId = regResult['session_id'] as String?; + if (sessionId == null) { + print(' FAILED (no session_id after registration)'); + return ScenarioResult( + name: name, + description: description, + batchSize: batchSize, + interBatchDelay: interBatchDelay, + batches: [], + failureReason: 'No session_id after registration', + authFailed: true, + ); + } + print(' OK via registration (session: $sessionId)'); + _activeSessionId = sessionId; + _activeApiKey = apiKey; + _activePubkey = pubkey; + _activeClient = client; + // Fall through to upload with sessionId + return _runBatches( + client: client, + apiKey: apiKey, + pubkey: pubkey, + lat: lat, + lon: lon, + dataLat: dataLat, + dataLon: dataLon, + sessionId: sessionId, + name: name, + description: description, + batchSize: batchSize, + maxBatches: maxBatches, + interBatchDelay: interBatchDelay, + ); + } + + print(' FAILED ($reason)'); + if (reason == 'unknown_device') { + print( + ' Hint: device not registered. Pass --contact-uri= or use a registered device key.'); + } + return ScenarioResult( + name: name, + description: description, + batchSize: batchSize, + interBatchDelay: interBatchDelay, + batches: [], + failureReason: 'Auth failed: $reason', + authFailed: true, + ); + } + + final sessionId = authResult['session_id'] as String?; + final expiresAt = authResult['expires_at'] as int?; + final elapsed = authResult['_elapsed_ms'] as int?; + if (sessionId == null) { + print(' FAILED (no session_id)'); + return ScenarioResult( + name: name, + description: description, + batchSize: batchSize, + interBatchDelay: interBatchDelay, + batches: [], + failureReason: 'Auth succeeded but no session_id', + authFailed: true, + ); + } + + final ttl = expiresAt != null + ? expiresAt - (DateTime.now().millisecondsSinceEpoch ~/ 1000) + : 0; + print(' OK (session: $sessionId, TTL: ${ttl}s, ${elapsed}ms)'); + + _activeSessionId = sessionId; + _activeApiKey = apiKey; + _activePubkey = pubkey; + _activeClient = client; + + return _runBatches( + client: client, + apiKey: apiKey, + pubkey: pubkey, + lat: lat, + lon: lon, + dataLat: dataLat, + dataLon: dataLon, + sessionId: sessionId, + name: name, + description: description, + batchSize: batchSize, + maxBatches: maxBatches, + interBatchDelay: interBatchDelay, + ); +} + +Future _runBatches({ + required http.Client client, + required String apiKey, + required String pubkey, + required double lat, + required double lon, + double? dataLat, + double? dataLon, + required String sessionId, + required String name, + required String description, + required int batchSize, + required int maxBatches, + required Duration interBatchDelay, +}) async { + final batches = []; + int? failedAtBatch; + int? failedAtCumulative; + String? failureReason; + + // Wait for session propagation + print(' Waiting 4s for session propagation...'); + await Future.delayed(const Duration(seconds: 4)); + + // Generate all test items up front (use data coords if provided, else auth coords) + final totalItems = batchSize * maxBatches; + final allItems = generateTestItems(totalItems, + baseLat: dataLat ?? lat, baseLon: dataLon ?? lon); + + // Upload batches + var cumulativeItems = 0; + for (var i = 0; i < maxBatches; i++) { + final batchNum = i + 1; + final start = i * batchSize; + final end = (start + batchSize).clamp(0, allItems.length); + final batch = allItems.sublist(start, end); + cumulativeItems += batch.length; + + stdout.write( + ' Batch $batchNum/$maxBatches (${batch.length} items, total: $cumulativeItems)...'); + + final result = await uploadBatch( + client: client, + apiKey: apiKey, + sessionId: sessionId, + items: batch, + batchNum: batchNum, + cumulativeItems: cumulativeItems, + ); + batches.add(result); + + if (result.success) { + final elapsed = + '${(result.responseTime.inMilliseconds / 1000).toStringAsFixed(2)}s'; + print(' OK ($elapsed)'); + } else { + final elapsed = + '${(result.responseTime.inMilliseconds / 1000).toStringAsFixed(2)}s'; + print( + ' FAIL ${result.httpStatus} ${result.reason ?? 'unknown'} ($elapsed)'); + if (result.message != null) { + print(' "${result.message}"'); + } + failedAtBatch = batchNum; + failedAtCumulative = cumulativeItems; + failureReason = result.reason; + break; + } + + // Inter-batch delay + if (interBatchDelay.inMilliseconds > 0 && i < maxBatches - 1) { + stdout.write( + ' (waiting ${interBatchDelay.inSeconds}s before next batch...)\n'); + await Future.delayed(interBatchDelay); + } + } + + // Disconnect + stdout.write(' Disconnecting...'); + await disconnectSession( + client: client, + apiKey: apiKey, + pubkey: pubkey, + sessionId: sessionId, + ); + print(' OK'); + + _activeSessionId = null; + _activeApiKey = null; + _activePubkey = null; + _activeClient = null; + + return ScenarioResult( + name: name, + description: description, + batchSize: batchSize, + interBatchDelay: interBatchDelay, + batches: batches, + failedAtBatch: failedAtBatch, + failedAtCumulativeItems: failedAtCumulative, + failureReason: failureReason, + ); +} + +// ============================================================================ +// OUTPUT +// ============================================================================ + +void printHeader(String apiKey, String pubkey, double lat, double lon, + double? dataLat, double? dataLon, String who, String scenarios) { + print(''); + print('=' * 60); + print(' MESHMAPPER OFFLINE UPLOAD DIAGNOSTIC'); + print('=' * 60); + print(''); + print(' Config:'); + print( + ' API Key: ${apiKey.length > 8 ? '${apiKey.substring(0, 4)}...${apiKey.substring(apiKey.length - 4)}' : '****'}'); + print( + ' Public Key: ${pubkey.length > 16 ? '${pubkey.substring(0, 8)}...${pubkey.substring(pubkey.length - 8)}' : pubkey}'); + print(' Device: $who'); + print(' Auth Coords: $lat, $lon'); + if (dataLat != null || dataLon != null) { + print(' Data Coords: ${dataLat ?? lat}, ${dataLon ?? lon}'); + } + print(' Scenarios: $scenarios'); + print(''); +} + +void printScenarioHeader(String name, String description) { + print('=' * 60); + print(' SCENARIO: $name'); + print(' $description'); + print('=' * 60); + print(''); +} + +void printSummary(List results) { + print(''); + print('=' * 60); + print(' DIAGNOSTIC SUMMARY'); + print('=' * 60); + print(''); + + for (final result in results) { + final icon = result.passed ? '[+]' : '[X]'; + final delayStr = result.interBatchDelay.inSeconds > 0 + ? '${result.interBatchDelay.inSeconds}s delay' + : 'no delay'; + + if (result.authFailed) { + print(' $icon ${result.name} (batch=${result.batchSize}, $delayStr)'); + print(' Auth failed: ${result.failureReason}'); + } else if (result.passed) { + print(' $icon ${result.name} (batch=${result.batchSize}, $delayStr)'); + print( + ' PASSED ${result.totalUploaded} items across ${result.batches.length} batches'); + } else { + print(' $icon ${result.name} (batch=${result.batchSize}, $delayStr)'); + print( + ' FAILED at batch ${result.failedAtBatch} (${result.failedAtCumulativeItems} cumulative items)'); + print(' Reason: ${result.failureReason}'); + print( + ' Successfully uploaded: ${result.totalUploaded} items before failure'); + } + print(''); + } + + // Analysis + final validResults = results.where((r) => !r.authFailed).toList(); + if (validResults.isEmpty) { + print(' FINDINGS:'); + print(' - All scenarios failed to authenticate.'); + print( + ' - Check your API key and device public key, or provide --contact-uri'); + } else { + print(' FINDINGS:'); + + final rapidFire = validResults + .where((r) => r.interBatchDelay.inSeconds == 0 && r.batchSize == 50) + .toList(); + final throttled = validResults + .where((r) => r.interBatchDelay.inSeconds > 0 && r.batchSize == 50) + .toList(); + final batchSweep = validResults + .where((r) => r.interBatchDelay.inSeconds == 0 && r.batchSize != 50) + .toList(); + + // Check if rapid fire failed but throttled passed + if (rapidFire.isNotEmpty && + throttled.isNotEmpty && + !rapidFire.first.passed && + throttled.first.passed) { + print( + ' - Rapid-fire batches fail at ~${rapidFire.first.totalUploaded} items, but throttled batches pass.'); + print(' - CONCLUSION: Server-side RATE LIMIT (not item count limit).'); + print( + ' - The server rejects rapid consecutive POSTs to the same session.'); + print( + ' - Workaround: add a delay between batch uploads (${throttled.first.interBatchDelay.inSeconds}s worked).'); + } else if (rapidFire.isNotEmpty && + throttled.isNotEmpty && + !rapidFire.first.passed && + !throttled.first.passed) { + final rapidItems = rapidFire.first.totalUploaded; + final throttledItems = throttled.first.totalUploaded; + if ((rapidItems - throttledItems).abs() < 60) { + print(' - Both rapid and throttled fail at ~$rapidItems items.'); + print( + ' - CONCLUSION: Server-side ITEM COUNT LIMIT (~$rapidItems per session).'); + print( + ' - Workaround: re-authenticate after every ~${(rapidItems * 0.8).round()} items.'); + } else { + print( + ' - Rapid: failed at $rapidItems items. Throttled: failed at $throttledItems items.'); + print( + ' - CONCLUSION: Mixed behavior — may be a combination of rate + count limits.'); + } + } else if (validResults.every((r) => r.passed)) { + print(' - All scenarios passed!'); + print(' - Could not reproduce the session invalidation in this run.'); + print( + ' - The issue may depend on server load, time of day, or specific session state.'); + } + + // Batch size analysis + if (batchSweep.length >= 2) { + print(''); + print(' Batch size analysis:'); + for (final r in batchSweep) { + final status = r.passed ? 'PASSED' : 'FAILED at ${r.totalUploaded}'; + print(' batch=${r.batchSize}: $status'); + } + } + + // Out-of-zone analysis + final oozResults = + validResults.where((r) => r.name == 'Out-of-Zone Data').toList(); + if (oozResults.isNotEmpty) { + print(''); + print(' Out-of-zone data analysis:'); + final ooz = oozResults.first; + if (ooz.passed) { + print( + ' PASSED — server accepted ${ooz.totalUploaded} items with out-of-zone coordinates.'); + print( + ' Zone mismatch between auth and ping data does NOT cause session invalidation.'); + } else { + print( + ' FAILED at batch ${ooz.failedAtBatch} (${ooz.failedAtCumulativeItems} cumulative items).'); + print(' Reason: ${ooz.failureReason}'); + if (ooz.failureReason == 'bad_session' || + ooz.failureReason == 'session_expired') { + print( + ' CONCLUSION: Server MAY invalidate sessions when ping coords are outside auth zone.'); + } + } + } + } + + print(''); + print('=' * 60); + print(' NOTE: Test data uses who="DIAG-TEST" and past timestamps.'); + print(' Ask the backend team to purge DIAG-TEST data after review.'); + print('=' * 60); + print(''); +} + +// ============================================================================ +// MAIN +// ============================================================================ + +Future main(List arguments) async { + if (arguments.isEmpty || arguments.contains('--help')) { + printUsage(); + return; + } + + // Parse arguments + String? apiKey; + String? pubkey; + String? contactUri; + double lat = 45.3215; + double lon = -75.6693; + double? dataLat; + double? dataLon; + String who = 'DIAG-TEST'; + String scenario = 'all'; + + for (final arg in arguments) { + if (arg.startsWith('--key=')) { + apiKey = arg.substring(6); + } else if (arg.startsWith('--pubkey=')) { + pubkey = arg.substring(9); + } else if (arg.startsWith('--contact-uri=')) { + contactUri = arg.substring(14); + } else if (arg.startsWith('--lat=')) { + lat = double.parse(arg.substring(6)); + } else if (arg.startsWith('--lon=')) { + lon = double.parse(arg.substring(6)); + } else if (arg.startsWith('--data-lat=')) { + dataLat = double.parse(arg.substring(11)); + } else if (arg.startsWith('--data-lon=')) { + dataLon = double.parse(arg.substring(11)); + } else if (arg.startsWith('--who=')) { + who = arg.substring(6); + } else if (arg.startsWith('--scenario=')) { + scenario = arg.substring(11); + } + } + + if (apiKey == null) { + print('Error: --key= is required'); + printUsage(); + exit(1); + } + if (pubkey == null && contactUri == null) { + print( + 'Error: --pubkey= or --contact-uri= is required'); + printUsage(); + exit(1); + } + + pubkey ??= ''; + + // Setup signal handler for Ctrl+C + ProcessSignal.sigint.watch().listen((_) async { + print('\n\n [CTRL+C] Cleaning up...'); + if (_activeSessionId != null && _activeClient != null) { + await disconnectSession( + client: _activeClient!, + apiKey: _activeApiKey!, + pubkey: _activePubkey!, + sessionId: _activeSessionId!, + ); + print(' Session disconnected.'); + } + _activeClient?.close(); + exit(1); + }); + + final client = http.Client(); + final results = []; + + printHeader(apiKey, pubkey, lat, lon, dataLat, dataLon, who, scenario); + + // Scenario 1: Rapid fire (reproduce the bug) + if (scenario == 'all' || scenario == '1') { + printScenarioHeader( + '1 - Rapid Fire', + 'Batch=50, no delay. Should reproduce failure at ~batch 4.', + ); + results.add(await runScenario( + client: client, + apiKey: apiKey, + pubkey: pubkey, + lat: lat, + lon: lon, + dataLat: dataLat, + dataLon: dataLon, + who: who, + contactUri: contactUri, + name: 'Rapid Fire', + description: 'batch=50, no delay', + batchSize: 50, + maxBatches: 10, + interBatchDelay: Duration.zero, + )); + print(''); + } + + // Scenario 2: Throttled (test rate limit hypothesis) + if (scenario == 'all' || scenario == '2') { + printScenarioHeader( + '2 - Throttled', + 'Batch=50, 3s delay between batches. Tests rate limit hypothesis.', + ); + results.add(await runScenario( + client: client, + apiKey: apiKey, + pubkey: pubkey, + lat: lat, + lon: lon, + dataLat: dataLat, + dataLon: dataLon, + who: who, + contactUri: contactUri, + name: 'Throttled', + description: 'batch=50, 3s delay', + batchSize: 50, + maxBatches: 10, + interBatchDelay: const Duration(seconds: 3), + )); + print(''); + } + + // Scenario 3: Batch size sweep + if (scenario == 'all' || scenario == '3') { + for (final size in [10, 25, 100]) { + final maxBatches = (200 / size).ceil(); + printScenarioHeader( + '3 - Batch Size $size', + 'Batch=$size, no delay, up to $maxBatches batches (${size * maxBatches} items).', + ); + results.add(await runScenario( + client: client, + apiKey: apiKey, + pubkey: pubkey, + lat: lat, + lon: lon, + dataLat: dataLat, + dataLon: dataLon, + who: who, + contactUri: contactUri, + name: 'Batch Size $size', + description: 'batch=$size, no delay', + batchSize: size, + maxBatches: maxBatches, + interBatchDelay: Duration.zero, + )); + print(''); + } + } + + // Scenario 4: Out-of-zone ping data + if (scenario == 'all' || scenario == '4') { + // Auth from in-zone coords, but ping data from far outside any zone + const oozLat = 50.0; + const oozLon = -90.0; + printScenarioHeader( + '4 - Out-of-Zone Data', + 'Auth at ($lat, $lon) but ping coords at ($oozLat, $oozLon). Tests zone mismatch.', + ); + results.add(await runScenario( + client: client, + apiKey: apiKey, + pubkey: pubkey, + lat: lat, + lon: lon, + dataLat: dataLat ?? oozLat, + dataLon: dataLon ?? oozLon, + who: who, + contactUri: contactUri, + name: 'Out-of-Zone Data', + description: 'auth in-zone, data out-of-zone', + batchSize: 50, + maxBatches: 3, + interBatchDelay: Duration.zero, + )); + print(''); + } + + printSummary(results); + client.close(); +} + +void printUsage() { + print(''' +MeshMapper Offline Upload Diagnostic + +Reproduces server-side session invalidation during offline batch uploads. +Runs four scenarios to determine if the server has an item count limit, +rate limit, zone mismatch issue, or other constraint. + +Usage: + dart run bin/test_offline_upload.dart --key= --pubkey= [options] + +Required: + --key= MeshMapper API key + --pubkey= Registered device public key (hex) + +Optional: + --contact-uri= Signed contact URI (if device not yet registered) + --lat= Auth latitude (default: 45.3215) + --lon= Auth longitude (default: -75.6693) + --data-lat= Ping data latitude (default: same as --lat) + --data-lon= Ping data longitude (default: same as --lon) + --who= Device name (default: DIAG-TEST) + --scenario=<1|2|3|4|all> Run specific scenario (default: all) + +Scenarios: + 1 Rapid fire Batch=50, no delay, 10 batches. Reproduces the bug. + 2 Throttled Batch=50, 3s delay, 10 batches. Tests rate limit. + 3 Batch sweep Batch=10/25/100, no delay. Tests per-batch vs cumulative limit. + 4 Out-of-zone data Auth in-zone, ping coords out-of-zone. Tests zone mismatch. + +Examples: + dart run bin/test_offline_upload.dart --key=abc123 --pubkey=deadbeef01234567 + dart run bin/test_offline_upload.dart --key=abc123 --pubkey=deadbeef01234567 --scenario=1 + dart run bin/test_offline_upload.dart --key=abc123 --contact-uri="meshcore://..." --scenario=2 + dart run bin/test_offline_upload.dart --key=abc123 --pubkey=deadbeef01234567 --scenario=4 --data-lat=50.0 --data-lon=-90.0 +'''); +} diff --git a/docs/CUSTOM_API_ENDPOINT.md b/docs/CUSTOM_API_ENDPOINT.md new file mode 100644 index 00000000..b4c919a8 --- /dev/null +++ b/docs/CUSTOM_API_ENDPOINT.md @@ -0,0 +1,250 @@ +# Custom API Endpoint - Third-Party Developer Guide + +This document describes the API contract for receiving forwarded wardrive data from MeshMapper. When a user enables "Custom API Endpoint" in MeshMapper settings, every successful wardrive batch upload is also forwarded to your endpoint. + +## Request Format + +| Field | Value | +|-------|-------| +| **Method** | `POST` | +| **Content-Type** | `application/json` | +| **Authentication** | `X-API-Key` header (value configured by the user in MeshMapper settings) | + +### Request Body + +```json +{ + "data": [ + { /* ping object */ }, + { /* ping object */ }, + ... + ] +} +``` + +The `data` array contains 1-50 ping objects per batch. Batches arrive approximately every 15-30 seconds during an active wardriving session. + +## Ping Object Schema + +Every ping object contains a `type` field that determines which additional fields are present. + +### Common Fields (all types) + +| Field | Type | Description | +|-------|------|-------------| +| `type` | `string` | Ping type: `"TX"`, `"RX"`, `"DISC"`, or `"TRACE"` | +| `lat` | `number` | Latitude (WGS84, decimal degrees) | +| `lon` | `number` | Longitude (WGS84, decimal degrees) | +| `timestamp` | `integer` | Unix timestamp in seconds | +| `external_antenna` | `boolean` | Whether an external antenna is connected to the device | +| `noisefloor` | `integer\|null` | Ambient noise floor in dBm (e.g., -103). Null if unavailable. | +| `power` | `string\|null` | Radio TX power formatted as `"X.Xw"` (e.g., `"0.3w"`, `"1.0w"`, `"2.0w"`). Null if unavailable. | +| `contact` | `string\|absent` | First 8 hex chars of the wardriver's MeshCore device public key (e.g., `"D873B1F2"`). Only present when the user enables "Include Contact Key" in settings. Useful for cross-referencing with MQTT observer data. | +| `iata` | `string\|absent` | MeshMapper zone code (e.g., `"RDU"`, `"MSP"`, `"YOW"`). Present when the wardriver is in a zone. | + +### TX Ping (type: "TX") + +A transmitted ping broadcast on the wardriving channel, with repeater echo results. + +| Field | Type | Description | +|-------|------|-------------| +| `heard_repeats` | `string` | Comma-separated repeater echoes: `"id(snr),id(snr)"` or `"None"` if no repeaters heard. IDs are hex strings (1-6 chars). SNR is in dB. | + +**Example:** + +```json +{ + "type": "TX", + "lat": 45.26974, + "lon": -75.77746, + "noisefloor": -103, + "heard_repeats": "4e(12.25),77(8.50)", + "timestamp": 1768762843, + "external_antenna": false, + "power": "0.3w", + "contact": "D873B1F2", + "iata": "YOW" +} +``` + +**Example (no repeaters heard):** + +```json +{ + "type": "TX", + "lat": 45.27001, + "lon": -75.77802, + "noisefloor": -101, + "heard_repeats": "None", + "timestamp": 1768762873, + "external_antenna": false, + "power": "0.3w", + "contact": "D873B1F2", + "iata": "YOW" +} +``` + +### RX Ping (type: "RX") + +A passively observed mesh packet from a nearby repeater. + +| Field | Type | Description | +|-------|------|-------------| +| `heard_repeats` | `string` | Single repeater with SNR: `"id(snr)"` (e.g., `"4e(12.0)"`). | + +**Example:** + +```json +{ + "type": "RX", + "lat": 45.26950, + "lon": -75.77700, + "noisefloor": -105, + "heard_repeats": "4e(12.00)", + "timestamp": 1768762900, + "external_antenna": false, + "power": "0.3w", + "contact": "D873B1F2", + "iata": "YOW" +} +``` + +### DISC Ping (type: "DISC") - Discovery Response + +A response from a discovered repeater or room node. + +| Field | Type | Description | +|-------|------|-------------| +| `repeater_id` | `string` | Hex ID of the discovered node (e.g., `"a3b2c1"`), or `"None"` for failed discovery. | +| `node_type` | `string` | Node type: `"REPEATER"` or `"ROOM"`. Absent on failed discovery. | +| `local_snr` | `number` | SNR measured locally (dB). | +| `local_rssi` | `integer` | RSSI measured locally (dBm, negative). | +| `remote_snr` | `number` | SNR reported by the remote node (dB). | +| `public_key` | `string` | Full 32-byte public key of the node (64 hex chars). | + +**Example (successful discovery):** + +```json +{ + "type": "DISC", + "lat": 45.26980, + "lon": -75.77750, + "noisefloor": -102, + "repeater_id": "a3b2c1", + "node_type": "REPEATER", + "local_snr": 11.75, + "local_rssi": -85, + "remote_snr": 9.50, + "public_key": "a3b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", + "timestamp": 1768762950, + "external_antenna": true, + "power": "1.0w", + "contact": "D873B1F2", + "iata": "YOW" +} +``` + +**Example (failed discovery - no nodes responded):** + +```json +{ + "type": "DISC", + "lat": 45.27010, + "lon": -75.77800, + "noisefloor": -100, + "repeater_id": "None", + "timestamp": 1768762980, + "external_antenna": true, + "power": "1.0w", + "contact": "D873B1F2", + "iata": "YOW" +} +``` + +### TRACE Ping (type: "TRACE") + +A targeted zero-hop trace to a specific repeater. + +| Field | Type | Description | +|-------|------|-------------| +| `repeater_id` | `string` | Hex ID of the targeted repeater. | +| `local_snr` | `number` | SNR measured locally (dB). | +| `local_rssi` | `integer` | RSSI measured locally (dBm, negative). | +| `remote_snr` | `number` | SNR reported by the repeater (dB). | + +**Example:** + +```json +{ + "type": "TRACE", + "lat": 45.26990, + "lon": -75.77730, + "noisefloor": -104, + "repeater_id": "4e2f", + "local_snr": 10.50, + "local_rssi": -88, + "remote_snr": 8.25, + "timestamp": 1768763010, + "external_antenna": false, + "power": "0.3w", + "contact": "D873B1F2", + "iata": "YOW" +} +``` + +## Expected Response + +Your endpoint should return any `2xx` HTTP status code on success. The response body is ignored by MeshMapper. + +| Status | MeshMapper Behavior | +|--------|-------------------| +| `200-299` | Logged as success. No further action. | +| `4xx` | Error logged to user's error tab (throttled to once per minute per status code). | +| `5xx` | Error logged to user's error tab (throttled to once per minute per status code). | +| Timeout (>10s) | Error logged as timeout. | +| Network error | Error logged as network error. | + +MeshMapper does **not** retry failed custom API requests. Each batch is sent exactly once. + +## Configuration Link for End Users + +As an endpoint operator, you can generate a configuration link that your users paste into their clipboard. In MeshMapper Settings > API Endpoints, they tap **Import from Clipboard** to auto-fill the URL and API key. + +**Format:** + +``` +meshmapper://custom-api?url=your-server.com/api/wardrive&key=your-api-key-here +``` + +- `url` — Your endpoint host and path, **without** `https://` (MeshMapper prepends it automatically). +- `key` — The API key the user should send. This will be set as their `X-API-Key` header value. + +**Example:** + +``` +meshmapper://custom-api?url=data.myproject.org/ingest/wardrive&key=sk_live_abc123def456 +``` + +The user copies this link, opens MeshMapper Settings > API Endpoints, and taps "Import from Clipboard." Both fields are populated instantly. + +## Security Notes + +- **HTTPS required**: MeshMapper validates that the configured URL uses HTTPS. HTTP endpoints are rejected at the settings level. +- **API key in header**: The user-configured API key is sent as `X-API-Key` header, not in the request body. +- **No MeshMapper credentials**: The MeshMapper API key and session ID are never included in forwarded requests. You receive only the raw ping data. +- **Contact key is opt-in**: The `contact` field (device public key prefix) is controlled by the user via "Include Contact Key" toggle. It defaults to ON but can be disabled. +- **Fire-and-forget**: Custom API errors never affect MeshMapper's primary data submission. A broken custom endpoint cannot disrupt wardriving. + +## Rate and Volume + +- **Batch frequency**: Every 15-30 seconds during active wardriving (depends on auto-ping interval and batch timer). +- **Batch size**: 1-50 ping objects per request (typically 1-10). +- **Session duration**: Wardriving sessions commonly last 30 minutes to several hours. +- **Concurrent users**: Plan for multiple users if distributing your endpoint URL. Each user sends independently. + +## Why `meshmapper://` links are paste-only + +The `meshmapper://custom-api?...` format is deliberately **not** registered as +an OS URL scheme. Tapping one does nothing; it has to be copied and imported +from Settings. The app registers `meshmapper-auth://callback` instead (portal +sign-in) precisely so that claiming a scheme never hijacks these config links. diff --git a/docs/LIVE_ACTIVITIES.md b/docs/LIVE_ACTIVITIES.md new file mode 100644 index 00000000..1e4b5a10 --- /dev/null +++ b/docs/LIVE_ACTIVITIES.md @@ -0,0 +1,29 @@ +# iOS Live Activities + +MeshMapper starts one read-only Live Activity while an automatic wardriving session or manual ping cycle is active. It mirrors the same state used by the in-app controls. + +## Displayed information + +- Current mode and phase (`Sending`, `Listening`, `Next ping`, `Cooldown`, GPS/zone/reconnect states) +- System-rendered countdowns based on absolute timer deadlines +- The strongest repeaters from the current or latest completed cycle, sorted by SNR +- TX/RX counters, upload queue, zone, connection state, and stale-update warnings +- Lock Screen, Dynamic Island, and an iOS 18 small-family layout — primarily the paired watch's Smart Stack card, and CarPlay on supported systems + +## Architecture + +- Dart builds a compact `LiveActivitySnapshot` from `AppStateProvider`. +- `LiveActivityService` deduplicates and throttles noncritical updates before sending them over `meshmapper/live_activity`. +- `LiveActivityManager` creates, updates, deduplicates, and ends the native ActivityKit activity. +- `MeshMapperLiveActivityExtension` renders the snapshot using SwiftUI and WidgetKit. + +No push server or App Group is required. The Live Activity does not replace the existing BLE/location background execution; it only presents its state. + +## Platform requirements + +- Live Activity: iOS 16.2 or later +- Small supplemental activity family (watch Smart Stack): iOS 18 or later +- CarPlay presentation of Live Activities: iOS 26 or later +- A physical iPhone is recommended for final background, Dynamic Island, and CarPlay validation; the Smart Stack card needs a paired watch + +The main Runner target keeps its existing deployment target. Unsupported devices skip ActivityKit creation. diff --git a/ios/ExportOptions.plist b/ios/ExportOptions.plist new file mode 100644 index 00000000..eb3a79ed --- /dev/null +++ b/ios/ExportOptions.plist @@ -0,0 +1,18 @@ + + + + + method + app-store-connect + teamID + DQC6TNKG5P + signingStyle + automatic + stripSwiftSymbols + + uploadSymbols + + destination + export + + diff --git a/ios/ExportOptionsUpload.plist b/ios/ExportOptionsUpload.plist new file mode 100644 index 00000000..2c453464 --- /dev/null +++ b/ios/ExportOptionsUpload.plist @@ -0,0 +1,39 @@ + + + + + method + app-store-connect + teamID + DQC6TNKG5P + + signingStyle + manual + signingCertificate + Apple Distribution + provisioningProfiles + + net.meshmapper.app + MeshMapper AppStore + net.meshmapper.app.liveactivity + MeshMapper LiveActivity AppStore + net.meshmapper.app.watchkitapp + MeshMapper Watch AppStore + net.meshmapper.app.appintents + MeshMapper AppIntents AppStore + + stripSwiftSymbols + + uploadSymbols + + destination + upload + + manageAppVersionAndBuildNumber + + + diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf76..391a902b 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/ios/Flutter/LiveActivity.xcconfig b/ios/Flutter/LiveActivity.xcconfig new file mode 100644 index 00000000..592ceee8 --- /dev/null +++ b/ios/Flutter/LiveActivity.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Watch.xcconfig b/ios/Flutter/Watch.xcconfig new file mode 100644 index 00000000..592ceee8 --- /dev/null +++ b/ios/Flutter/Watch.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/ephemeral/flutter_lldb_helper.py b/ios/Flutter/ephemeral/flutter_lldb_helper.py deleted file mode 100644 index a88caf99..00000000 --- a/ios/Flutter/ephemeral/flutter_lldb_helper.py +++ /dev/null @@ -1,32 +0,0 @@ -# -# Generated file, do not edit. -# - -import lldb - -def handle_new_rx_page(frame: lldb.SBFrame, bp_loc, extra_args, intern_dict): - """Intercept NOTIFY_DEBUGGER_ABOUT_RX_PAGES and touch the pages.""" - base = frame.register["x0"].GetValueAsAddress() - page_len = frame.register["x1"].GetValueAsUnsigned() - - # Note: NOTIFY_DEBUGGER_ABOUT_RX_PAGES will check contents of the - # first page to see if handled it correctly. This makes diagnosing - # misconfiguration (e.g. missing breakpoint) easier. - data = bytearray(page_len) - data[0:8] = b'IHELPED!' - - error = lldb.SBError() - frame.GetThread().GetProcess().WriteMemory(base, data, error) - if not error.Success(): - print(f'Failed to write into {base}[+{page_len}]', error) - return - -def __lldb_init_module(debugger: lldb.SBDebugger, _): - target = debugger.GetDummyTarget() - # Caveat: must use BreakpointCreateByRegEx here and not - # BreakpointCreateByName. For some reasons callback function does not - # get carried over from dummy target for the later. - bp = target.BreakpointCreateByRegex("^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$") - bp.SetScriptCallbackFunction('{}.handle_new_rx_page'.format(__name__)) - bp.SetAutoContinue(True) - print("-- LLDB integration loaded --") diff --git a/ios/Flutter/ephemeral/flutter_lldbinit b/ios/Flutter/ephemeral/flutter_lldbinit deleted file mode 100644 index e3ba6fbe..00000000 --- a/ios/Flutter/ephemeral/flutter_lldbinit +++ /dev/null @@ -1,5 +0,0 @@ -# -# Generated file, do not edit. -# - -command script import --relative-to-command-file flutter_lldb_helper.py diff --git a/ios/MeshMapperAppIntents/Info.plist b/ios/MeshMapperAppIntents/Info.plist new file mode 100644 index 00000000..9f8e7e7c --- /dev/null +++ b/ios/MeshMapperAppIntents/Info.plist @@ -0,0 +1,27 @@ + + + + + CFBundleDisplayName + MeshMapper Intents + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + EXAppExtensionAttributes + + EXExtensionPointIdentifier + com.apple.appintents-extension + + + diff --git a/ios/MeshMapperAppIntents/MeshMapperAppIntents.entitlements b/ios/MeshMapperAppIntents/MeshMapperAppIntents.entitlements new file mode 100644 index 00000000..7037779d --- /dev/null +++ b/ios/MeshMapperAppIntents/MeshMapperAppIntents.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.net.meshmapper.app.shared + + + diff --git a/ios/MeshMapperAppIntents/MeshMapperAppIntentsExtension.swift b/ios/MeshMapperAppIntents/MeshMapperAppIntentsExtension.swift new file mode 100644 index 00000000..db37f461 --- /dev/null +++ b/ios/MeshMapperAppIntents/MeshMapperAppIntentsExtension.swift @@ -0,0 +1,6 @@ +import AppIntents +import ExtensionFoundation + +@main +@available(iOS 26.0, *) +struct MeshMapperAppIntentsExtension: AppIntentsExtension {} diff --git a/ios/MeshMapperAppIntents/MeshMapperReadIntents.swift b/ios/MeshMapperAppIntents/MeshMapperReadIntents.swift new file mode 100644 index 00000000..b4d5e70f --- /dev/null +++ b/ios/MeshMapperAppIntents/MeshMapperReadIntents.swift @@ -0,0 +1,342 @@ +import AppIntents +import Foundation + +/// How the read-only intents decide whether the cached snapshot is recent +/// enough to speak about in the present tense. +/// +/// The App Group file deliberately outlives Runner, so every "currently" or +/// "this session" claim has to be checked against `updatedAt` first. +enum MeshMapperSnapshotFreshness { + static let currentClaimLimit: TimeInterval = 5 * 60 + + static func relativeAge(_ age: TimeInterval) -> String { + let seconds = max(Int(age), 0) + if seconds < 60 { return "\(counted(seconds, "second")) ago" } + if seconds < 60 * 60 { return "\(counted(seconds / 60, "minute")) ago" } + if seconds < 24 * 60 * 60 { + return "\(counted(seconds / (60 * 60), "hour")) ago" + } + return "\(counted(seconds / (24 * 60 * 60), "day")) ago" + } + + /// This is spoken aloud, so "1 seconds ago" is heard rather than skimmed. + /// + /// Shared with the status intent's ping and repeater counts, which are read + /// out in the same sentence and have the same problem. + static func counted(_ count: Int, _ unit: String) -> String { + "\(count) \(unit)\(count == 1 ? "" : "s")" + } +} + +@available(iOS 26.0, *) +enum RecentHeardWindow: String, AppEnum { + case lastFiveMinutes + case lastFifteenMinutes + case lastHour + case currentSession + + static var typeDisplayRepresentation = TypeDisplayRepresentation( + name: "Time Window" + ) + static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [ + .lastFiveMinutes: "Last 5 Minutes", + .lastFifteenMinutes: "Last 15 Minutes", + .lastHour: "Last Hour", + .currentSession: "Current Session", + ] + + /// Nil for `.currentSession`, which is bounded by the session's own start + /// rather than by a span. Treating that nil as "no boundary" is what made + /// Current Session silently mean "everything cached"; resolve it against the + /// snapshot instead. + var duration: TimeInterval? { + switch self { + case .lastFiveMinutes: return 5 * 60 + case .lastFifteenMinutes: return 15 * 60 + case .lastHour: return 60 * 60 + case .currentSession: return nil + } + } +} + +@available(iOS 26.0, *) +enum RepeaterSort: String, AppEnum { + case mostRecent + case strongest + case farthest + + static var typeDisplayRepresentation = TypeDisplayRepresentation( + name: "Repeater Sort" + ) + static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [ + .mostRecent: "Most Recent", + .strongest: "Strongest", + .farthest: "Farthest", + ] +} + +@available(iOS 26.0, *) +struct GetMeshMapperStatusIntent: AppIntent { + static let title: LocalizedStringResource = "Get MeshMapper Status" + static let description = IntentDescription( + "Reports the cached connection and mapping-session status without launching MeshMapper." + ) + static var authenticationPolicy: IntentAuthenticationPolicy { + .requiresAuthentication + } + static var supportedModes: IntentModes { .background } + + func perform() async throws -> some IntentResult & ProvidesDialog { + guard let snapshot = try MeshMapperSiriSnapshotStore.shared.read() else { + return .result(dialog: "Open MeshMapper once before asking for its status.") + } + + let age = Date().timeIntervalSince(snapshot.updatedAt) + if age > MeshMapperSnapshotFreshness.currentClaimLimit, + snapshot.session.active || snapshot.session.starting { + return .result( + dialog: "MeshMapper's last session update is too old to report as current. Open the app to refresh it." + ) + } + + let prefix: String + if age >= 30 { + prefix = "As of \(MeshMapperSnapshotFreshness.relativeAge(age)), " + } else { + prefix = "" + } + + if !snapshot.connection.isConnected { + return .result(dialog: "\(prefix)MeshMapper wasn't connected to a companion.") + } + if snapshot.session.starting { + return .result( + dialog: "\(prefix)MeshMapper was starting in \(Self.modeName(snapshot.session.mode)) mode." + ) + } + if !snapshot.session.active { + let device = snapshot.connection.deviceName.map { " to \($0)" } ?? "" + return .result( + dialog: "\(prefix)MeshMapper was connected\(device), but no mapping session was running." + ) + } + + let zone = snapshot.session.zoneCode.map { " in \($0)" } ?? "" + let detail = snapshot.session.phaseDetail.map { " \($0)." } ?? "" + let pings = MeshMapperSnapshotFreshness.counted( + snapshot.session.txCount, "ping" + ) + let heard = MeshMapperSnapshotFreshness.counted( + snapshot.session.uniqueRepeatersHeard, "unique repeater" + ) + return .result( + dialog: "\(prefix)MeshMapper was running in \(Self.modeName(snapshot.session.mode)) mode\(zone). It had sent \(pings) and heard \(heard). \(snapshot.session.phaseTitle).\(detail)" + ) + } + + /// The wire mode names are not speakable. `targeted` is only ever called + /// Trace on screen, and `passive` is plain Passive in the app's own copy, so + /// capitalizing the raw value would say words no MeshMapper screen uses. + /// Kept in step with `AutoMode.displayName`. + private static func modeName(_ mode: String) -> String { + switch mode { + case "active": return "Active" + case "passive": return "Passive" + case "hybrid": return "Hybrid" + case "targeted": return "Trace" + default: return mode.capitalized + } + } +} + +@available(iOS 26.0, *) +struct GetRecentlyHeardRepeatersIntent: AppIntent { + static let title: LocalizedStringResource = "Get Recently Heard Repeaters" + static let description = IntentDescription( + "Returns recent MeshMapper repeater observations from a bounded local cache." + ) + static var authenticationPolicy: IntentAuthenticationPolicy { + .requiresAuthentication + } + static var supportedModes: IntentModes { .background } + + @Parameter(title: "Within", default: .lastFifteenMinutes) + var timeWindow: RecentHeardWindow + + @Parameter(title: "Sort By", default: .mostRecent) + var sort: RepeaterSort + + @Parameter(title: "Maximum Results", default: 3, inclusiveRange: (1, 5)) + var limit: Int + + func perform() async throws -> some IntentResult & ReturnsValue<[HeardRepeaterEntity]> & ProvidesDialog { + guard let snapshot = try MeshMapperSiriSnapshotStore.shared.read() else { + return .result( + value: [], + dialog: "Open MeshMapper once before asking what it heard." + ) + } + + let now = Date() + // "Current Session" is a session boundary, not the absence of one. The + // cached history spans two hours regardless of how many sessions ran in it, + // so without the session's own start there is nothing to filter against. + let cutoff: Date + if timeWindow == .currentSession { + let age = now.timeIntervalSince(snapshot.updatedAt) + guard age <= MeshMapperSnapshotFreshness.currentClaimLimit else { + return .result( + value: [], + dialog: "MeshMapper's last update is too old to say what the current session has heard. Open the app to refresh it." + ) + } + guard snapshot.session.active || snapshot.session.starting, + let startedAt = snapshot.session.startedAt + else { + return .result( + value: [], + dialog: "No MeshMapper mapping session is running." + ) + } + cutoff = startedAt + } else { + cutoff = timeWindow.duration.map { now.addingTimeInterval(-$0) } + ?? .distantPast + } + + var observations = snapshot.recentHeard.filter { $0.observedAt >= cutoff } + switch sort { + case .mostRecent: + observations.sort { $0.observedAtMs > $1.observedAtMs } + case .strongest: + observations.sort { ($0.snr ?? -.infinity) > ($1.snr ?? -.infinity) } + case .farthest: + observations.sort { + ($0.distanceM ?? -.infinity) > ($1.distanceM ?? -.infinity) + } + } + + // The parameter range guides Siri/Shortcuts, while the clamp preserves + // safety for an older saved Shortcut carrying an out-of-range value. + let requestedLimit = min(max(limit, 1), 5) + let selected = Array(observations.prefix(requestedLimit)) + let repeaterById = Dictionary( + snapshot.repeaters.map { ($0.id, $0) }, + uniquingKeysWith: { existing, _ in existing } + ) + let entities = selected.map { + HeardRepeaterEntity(observation: $0, repeaterById: repeaterById) + } + guard !selected.isEmpty else { + let scope = timeWindow == .currentSession + ? "yet in this session" + : "in that time window" + return .result( + value: [], + dialog: "MeshMapper hasn't heard any repeaters \(scope)." + ) + } + + // Naming every result would outstay its welcome when this is heard through + // AirPods with nothing on screen, but silently dropping the rest would make + // the spoken answer disagree with the returned value. Say how many are left. + let spokenCount = min(selected.count, Self.maximumSpokenResults) + let spoken = selected + .prefix(spokenCount) + .map(Self.spokenObservation) + .joined(separator: ", ") + let remaining = selected.count - spokenCount + let lead: String + if selected.count == 1 { + lead = "The result was" + } else if remaining > 0 { + lead = "The first \(spokenCount) were" + } else { + lead = "The results were" + } + let tail: String + switch remaining { + case 0: tail = "" + case 1: tail = " There is 1 more result." + default: tail = " There are \(remaining) more results." + } + return .result(value: entities, dialog: "\(lead) \(spoken).\(tail)") + } + + private static let maximumSpokenResults = 3 + + private static func spokenObservation(_ observation: MeshMapperSiriObservation) -> String { + let identity = observation.name ?? "repeater \(observation.displayHexId)" + let signal = observation.snr.map { String(format: " at %.1f dB", $0) } ?? "" + let distance: String + if let meters = observation.distanceM { + distance = meters >= 1_000 + ? String(format: ", about %.1f kilometers away", meters / 1_000) + : String(format: ", about %.0f meters away", meters) + } else { + distance = "" + } + return "\(identity)\(signal)\(distance)" + } +} + +/// TODO: Not currently offered as a spoken App Shortcut. +/// +/// Spoken lookup did not work in on-device testing, so the phrase was withdrawn +/// from `MeshMapperAppShortcuts` rather than hold up the branch. The intent +/// itself is still built into both targets and remains available in the +/// Shortcuts app, so re-registering is a matter of restoring the `AppShortcut` +/// entry once the disambiguation flow is understood. Suspects worth checking +/// first: `RepeaterEntityQuery.entities(matching:)` matching against spoken +/// text, and an empty or stale catalogue in the App Group snapshot. +@available(iOS 26.0, *) +struct FindMeshMapperRepeaterIntent: AppIntent { + // Named for what it can actually search. The App Group catalog holds the + // active and most recently heard repeaters, not every repeater MeshMapper has + // loaded, so a broader name would fail unpredictably on the ones it omits. + static let title: LocalizedStringResource = "Find Recent MeshMapper Repeater" + static let description = IntentDescription( + "Looks up one of the active or recently heard repeaters MeshMapper has cached, and says how current that reading is." + ) + // Unlocking is not required because nothing private is read. This answers + // from the cached catalog of publicly broadcast repeater identities, and + // never touches the user's location or their session activity. The two read + // intents above do gate: both speak what this device has been doing, which + // is the part worth keeping behind the lock screen. + static var authenticationPolicy: IntentAuthenticationPolicy { .alwaysAllowed } + static var supportedModes: IntentModes { .background } + + @Parameter(title: "Repeater") + var repeater: RepeaterEntity + + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + let snapshot = try? MeshMapperSiriSnapshotStore.shared.read() + // Prefer the catalog's own record: a saved Shortcut can carry an entity + // resolved long ago, and its captured state is what would otherwise be + // spoken as "currently". + let current = snapshot?.repeaters.first { $0.id == repeater.id } + let status = (current?.isActive ?? repeater.isActive) ? "active" : "stale" + let zone = repeater.zoneCode.map { " in \($0)" } ?? "" + let identity = "\(repeater.name) is repeater \(repeater.hexId)\(zone)" + + // The snapshot deliberately outlives Runner, so it can be days old. Only + // claim the present tense when it is recent enough to mean anything. + guard let snapshot else { + return .result( + value: repeater, + dialog: "\(identity). Open MeshMapper to see whether it's still active." + ) + } + let age = Date().timeIntervalSince(snapshot.updatedAt) + guard age <= MeshMapperSnapshotFreshness.currentClaimLimit else { + return .result( + value: repeater, + dialog: "\(identity). When MeshMapper last updated \(MeshMapperSnapshotFreshness.relativeAge(age)), it was \(status)." + ) + } + return .result( + value: repeater, + dialog: "\(identity), and is currently \(status)." + ) + } +} diff --git a/ios/MeshMapperLiveActivity/Assets.xcassets/Contents.json b/ios/MeshMapperLiveActivity/Assets.xcassets/Contents.json new file mode 100644 index 00000000..d8b757af --- /dev/null +++ b/ios/MeshMapperLiveActivity/Assets.xcassets/Contents.json @@ -0,0 +1,3 @@ +{ + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/ios/MeshMapperLiveActivity/Assets.xcassets/LiveActivityLogo.imageset/Contents.json b/ios/MeshMapperLiveActivity/Assets.xcassets/LiveActivityLogo.imageset/Contents.json new file mode 100644 index 00000000..6f7aaf27 --- /dev/null +++ b/ios/MeshMapperLiveActivity/Assets.xcassets/LiveActivityLogo.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "filename" : "logo_14.png", "idiom" : "universal", "scale" : "1x" }, + { "filename" : "logo_28.png", "idiom" : "universal", "scale" : "2x" }, + { "filename" : "logo_42.png", "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/ios/MeshMapperLiveActivity/Assets.xcassets/LiveActivityLogo.imageset/logo_14.png b/ios/MeshMapperLiveActivity/Assets.xcassets/LiveActivityLogo.imageset/logo_14.png new file mode 100644 index 00000000..4c331531 Binary files /dev/null and b/ios/MeshMapperLiveActivity/Assets.xcassets/LiveActivityLogo.imageset/logo_14.png differ diff --git a/ios/MeshMapperLiveActivity/Assets.xcassets/LiveActivityLogo.imageset/logo_28.png b/ios/MeshMapperLiveActivity/Assets.xcassets/LiveActivityLogo.imageset/logo_28.png new file mode 100644 index 00000000..4ff19b0f Binary files /dev/null and b/ios/MeshMapperLiveActivity/Assets.xcassets/LiveActivityLogo.imageset/logo_28.png differ diff --git a/ios/MeshMapperLiveActivity/Assets.xcassets/LiveActivityLogo.imageset/logo_42.png b/ios/MeshMapperLiveActivity/Assets.xcassets/LiveActivityLogo.imageset/logo_42.png new file mode 100644 index 00000000..37c3416e Binary files /dev/null and b/ios/MeshMapperLiveActivity/Assets.xcassets/LiveActivityLogo.imageset/logo_42.png differ diff --git a/ios/MeshMapperLiveActivity/Info.plist b/ios/MeshMapperLiveActivity/Info.plist new file mode 100644 index 00000000..9ff3b6bb --- /dev/null +++ b/ios/MeshMapperLiveActivity/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDisplayName + MeshMapper + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + NSSupportsLiveActivities + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift new file mode 100644 index 00000000..a5aee16e --- /dev/null +++ b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift @@ -0,0 +1,1281 @@ +import ActivityKit +import SwiftUI +import WidgetKit + +struct MeshMapperLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: MeshMapperActivityAttributes.self) { context in + Group { + if #available(iOSApplicationExtension 18.0, *) { + MeshMapperResponsiveActivityView(context: context) + } else { + MeshMapperLockScreenView(context: context) + } + } + .activityBackgroundTint(MeshMapperPalette.background) + .activitySystemActionForegroundColor(.white) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + MeshMapperModeBadge(mode: context.state.mode) + } + DynamicIslandExpandedRegion(.trailing) { + MeshMapperIslandOutcome(state: context.state, isStale: context.isStale) + } + DynamicIslandExpandedRegion(.center) { + Text(context.state.zoneCode ?? context.state.connectionLabel) + .font(.caption2.weight(.semibold)) + .foregroundStyle(context.isStale ? Color.orange : .secondary) + .lineLimit(1) + } + DynamicIslandExpandedRegion(.bottom) { + MeshMapperIslandBottom(state: context.state) + } + } compactLeading: { + Image( + systemName: context.isStale + ? "exclamationmark.triangle.fill" : context.state.phaseSymbol + ) + .foregroundStyle(context.isStale ? Color.orange : context.state.phaseColor) + .accessibilityLabel(context.isStale ? "Update delayed" : context.state.phaseTitle) + } compactTrailing: { + MeshMapperCompactTrailing(state: context.state) + } minimal: { + if context.isStale { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .accessibilityLabel("Update delayed") + } else { + // With room for one mark, the latest result matters more than mode + // or phase. This remains useful when an unanswered ping has no rows. + MeshMapperOutcomeDot(state: context.state, diameter: 9) + } + } + // The keyline is container chrome, not a second outcome indicator. A + // failed ping is routine and should not turn the whole island red. + .keylineTint(MeshMapperPalette.accent) + } + .meshMapperSupplementalActivityFamilies() + } +} + +extension ActivityConfiguration { + fileprivate func meshMapperSupplementalActivityFamilies() -> some WidgetConfiguration { + if #available(iOSApplicationExtension 18.0, *) { + return supplementalActivityFamilies([.small]) + } else { + return self + } + } +} + +@available(iOSApplicationExtension 18.0, *) +private struct MeshMapperResponsiveActivityView: View { + @Environment(\.activityFamily) private var activityFamily + let context: ActivityViewContext + + var body: some View { + if activityFamily == .small { + MeshMapperSmallActivityContent( + state: context.state, + isStale: context.isStale + ) + } else { + MeshMapperLockScreenContent( + state: context.state, + isStale: context.isStale + ) + } + } +} + +private struct MeshMapperLockScreenView: View { + let context: ActivityViewContext + + var body: some View { + MeshMapperLockScreenContent( + state: context.state, + isStale: context.isStale + ) + } +} + +private struct MeshMapperLockScreenContent: View { + let state: MeshMapperActivityAttributes.ContentState + let isStale: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + MeshMapperPhaseBar( + state: state, + titleFont: .subheadline.weight(.semibold), + countdownFont: .subheadline.monospacedDigit().weight(.semibold), + countdownWidth: 54 + ) + + HStack(spacing: 8) { + MeshMapperModeBadge(mode: state.mode) + // Counts describe the session, so they belong beside its mode. Keeping + // them out of the repeater stack prevents the last row from reading as + // though TX, RX, and queue values belong to that individual node. + MeshMapperMetrics(state: state, compact: false) + Spacer(minLength: 6) + MeshMapperStatusLabel(state: state, isStale: isStale) + } + + MeshMapperRepeaterSummary( + state: state, + limit: 3, + showsNames: true, + rowFont: .caption + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .foregroundStyle(.white) + } +} + +/// The small activity family card: the watch Smart Stack and, on iOS 26, +/// the CarPlay dashboard. +/// +/// **The layout is a band over rows.** A slate band carries the session's +/// status facts (phase on the left, mode and zone in the middle, noise floor +/// and battery pinned to the far right, each beside its app icon) above a +/// hairline divider, and the body below is up to three named repeater rows. +/// +/// **Width decides how much the band spells out.** The watch card is +/// 152–191 pt wide per the HIG's watchOS widget dimensions; CarPlay's is +/// wider. Past 200 pt the band adds the mode word, the battery percentage and +/// a live countdown, and the body seats a third row; under it the icons carry +/// the middle, the battery keeps only its glyph (until it runs low), and two +/// rows fit. +/// +/// **The energy rules are inherited, not relaxed.** Nothing here holds state +/// or animates, and the ticking countdown is width-gated to the wide layout: +/// a `Text(timerInterval:)` is redrawn by the system every second it is +/// visible, and on the wrist that redraw was measured at 80 % of this app's +/// display energy. `repeatersAreCurrent` still reaches the reader as the +/// rows' opacity. +/// +/// **The name is the only elastic element in a row.** The hex ID stays the +/// observation's identity (a 1-byte hash is frequently ambiguous) and the SNR +/// keeps its width and alignment, so a long resolved name truncates between +/// them rather than disturbing either. +@available(iOSApplicationExtension 18.0, *) +private struct MeshMapperSmallActivityContent: View { + let state: MeshMapperActivityAttributes.ContentState + let isStale: Bool + + /// Watch cards top out at 191 pt; anything wider is a surface (CarPlay) + /// that can afford the spelled-out band and the third row. + private static let wideThreshold: CGFloat = 200 + + /// Width past which the band can afford the spelled-out mode word on top + /// of everything else it carries. + private static let modeWordThreshold: CGFloat = 280 + + var body: some View { + GeometryReader { geo in + let isWide = geo.size.width >= Self.wideThreshold + let showsModeWord = geo.size.width >= Self.modeWordThreshold + VStack(alignment: .leading, spacing: 0) { + headerBand(isWide: isWide, showsModeWord: showsModeWord) + heardRows(isWide: isWide, width: geo.size.width) + // Rows from a finished cycle are still worth showing, but they are + // not a claim about now; the dimming is that statement. + .opacity(state.repeatersAreCurrent ? 1 : 0.55) + .padding(.horizontal, isWide ? 12 : 7) + .padding(.vertical, isWide ? 6 : 4) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } + .foregroundStyle(.white) + } + + // MARK: Header band + + private func headerBand(isWide: Bool, showsModeWord: Bool) -> some View { + HStack(spacing: isWide ? 5 : 2) { + if isStale { + Label("Update delayed", systemImage: "exclamationmark.triangle.fill") + .font(.system(size: isWide ? 12 : 11, weight: .semibold)) + .foregroundStyle(.orange) + .lineLimit(1) + .minimumScaleFactor(0.85) + } else if isWide { + Text(state.bandPhaseTitle) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle( + state.phaseColor.opacity(state.phaseDeadlineHasLapsed ? 0.45 : 1) + ) + .lineLimit(1) + // Shrink before truncating: a title clipped to "Liste…" reads as a + // different word; a title one point smaller reads as the same one. + .minimumScaleFactor(0.7) + .truncationMode(.tail) + .layoutPriority(1) + // The system draws and ticks this itself; no ActivityKit updates. + // Wide cards only: on the wrist a ticking timer was measured at 80 % + // of this app's display energy, so the watch keeps the title alone. + if isWide, state.activeCountdownRange != nil { + MeshMapperCountdown( + state: state, + font: .system(size: 12, weight: .semibold).monospacedDigit() + ) + .foregroundStyle(.secondary) + .fixedSize() + } + } else { + // The watch band: the logo carries identity and a single word carries + // state. A forward-looking title like "Next ping" read as orphaned + // there with no countdown beside it, which testers called out. + MeshMapperLogoMark(size: 14) + Text(state.narrowStatusWord) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle( + state.phaseColor.opacity(state.phaseDeadlineHasLapsed ? 0.45 : 1) + ) + .lineLimit(1) + // "Listening" is the longest word and the only one that needs the + // deeper floor; the short outcome words render at full size. + .minimumScaleFactor(0.65) + .layoutPriority(1) + } + Spacer(minLength: 4) + middleIdentity(isWide: isWide, showsModeWord: showsModeWord) + Spacer(minLength: 4) + trailingHealth(isWide: isWide, showsModeWord: showsModeWord) + } + .padding(.horizontal, isWide ? 12 : 7) + .padding(.vertical, isWide ? 6 : 4) + .background(MeshMapperPalette.surface) + .overlay(alignment: .bottom) { + Rectangle() + .fill(MeshMapperPalette.hairline) + .frame(height: 0.5) + } + } + + /// Mode and zone, each beside the icon the app itself uses for it. Narrow + /// cards drop the mode word and let the icon carry it. + private func middleIdentity(isWide: Bool, showsModeWord: Bool) -> some View { + HStack(spacing: isWide ? 4 : 3) { + // The mode icon earns its place only beside the wide band's title; on + // the watch the logo owns identity and the zone reads better bigger. + if isWide { + Image(systemName: state.modeSymbol) + .font(.system(size: 9, weight: .bold)) + } + if showsModeWord { + Text(state.mode.uppercased()) + .font(.system(size: 10, weight: .bold)) + .tracking(0.3) + } + if let zone = state.zoneCode { + // The pin is the least informative pixel on the 40 mm card, and its + // width is exactly what "Listening" needs to render unclipped. + if isWide { + Image(systemName: "mappin.and.ellipse") + .font(.system(size: 9, weight: .bold)) + } + Text(zone) + .font(.system(size: isWide ? 10 : 9, weight: .semibold, design: .monospaced)) + } else if !state.isConnected { + Text("Disconnected") + .font(.system(size: isWide ? 10 : 9, weight: .semibold)) + .foregroundStyle(.orange) + } + } + .foregroundStyle(.secondary) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + + /// Noise floor and companion battery, pinned to the band's far right. Both + /// are optional payload fields, so a radio that has not answered a stats + /// poll simply leaves the slot empty. + private func trailingHealth(isWide: Bool, showsModeWord: Bool) -> some View { + HStack(spacing: isWide ? 4 : 3) { + if let noiseFloor = state.noiseFloorDbm { + HStack(spacing: 2) { + Image(systemName: "waveform") + .font(.system(size: isWide ? 9 : 8, weight: .bold)) + Text("\(noiseFloor)") + .font(.system(size: isWide ? 10 : 9, weight: .bold, design: .monospaced)) + } + .foregroundStyle(Self.noiseFloorColor(noiseFloor)) + } + if let battery = state.companionBatteryPct { + HStack(spacing: 2) { + Image(systemName: Self.batterySymbol(battery)) + .font(.system(size: isWide ? 10 : 9, weight: .semibold)) + // The number earns its width only on the widest tier; below it + // the glyph is the signal, turning red when the battery is in + // trouble, and the number's width belongs to the status word. + if showsModeWord { + Text("\(battery)") + .font(.system(size: isWide ? 10 : 9, weight: .bold, design: .monospaced)) + } + } + .foregroundStyle(battery < 20 ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary)) + } + } + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + + /// The noise floor graph's own thresholds: quiet green to -100 dBm, busy + /// orange to -90, loud red past it. + private static func noiseFloorColor(_ dbm: Int) -> Color { + if dbm <= -100 { return .green } + if dbm <= -90 { return .orange } + return .red + } + + private static func batterySymbol(_ percent: Int) -> String { + switch percent { + case 90...: return "battery.100" + case 60..<90: return "battery.75" + case 35..<60: return "battery.50" + case 10..<35: return "battery.25" + default: return "battery.0" + } + } + + // MARK: Heard rows + + @ViewBuilder + private func heardRows(isWide: Bool, width: CGFloat) -> some View { + if state.repeaters.isEmpty { + HStack(spacing: 6) { + // A failed ping produces no rows, so its colour needs its own mark; + // absence alone cannot distinguish failure from no attempt yet. + MeshMapperOutcomeDot(state: state, diameter: 6) + Text(state.repeaterEmptyLabel) + .font(.system(size: isWide ? 12 : 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + } else if state.showRepeaterNames ?? true { + VStack(alignment: .leading, spacing: isWide ? 3 : 2) { + ForEach(state.repeaters.prefix(isWide ? 3 : 2)) { repeater in + namedRow(repeater, isWide: isWide) + } + } + } else { + hexGrid(isWide: isWide, width: width) + } + } + + /// The nameless alternative behind the Settings switch: `[dot hex SNR]` + /// packed two columns wide, column-major, four observations deep, the + /// layout the card shipped with before names arrived. More repeaters per + /// glance, no identity beyond the hex. + /// + /// Font and column count are solved from the measured width: rigid rows at + /// a fixed size overflowed the 40 mm card as soon as 3-byte path mode + /// produced 6-character IDs. + private func hexGrid(isWide: Bool, width: CGFloat) -> some View { + let rows = Array(state.repeaters.prefix(4)) + let columnGap: CGFloat = isWide ? 16 : 8 + let contentWidth = width - 2 * (isWide ? 12 : 7) + let idChars = CGFloat(rows.map { $0.id.count }.max() ?? 2) + let snrChars = CGFloat( + rows.map { Self.formattedRowSnr($0.snr, signed: false).count }.max() ?? 4 + ) + // Advance of one semibold monospaced character is about 0.62 em; the dot + // and its two gaps consume about 12 points of each column. + let columnWidth = (contentWidth - columnGap) / 2 - 2 + let widthBound = (columnWidth - 12) / (0.62 * (idChars + snrChars)) + // Below 8 points the type stops paying for itself and one clearer column + // wins. Truncating the ID is never the trade: the ID is the identity. + let usesTwoColumns = widthBound >= 8 + let cap: CGFloat = isWide ? 13 : 10 + let fontSize = min(max(usesTwoColumns ? widthBound : cap, 8), cap) + + return HStack(alignment: .top, spacing: columnGap) { + hexColumn(Array(rows.prefix(2)), isWide: isWide, fontSize: fontSize) + if usesTwoColumns, rows.count > 2 { + hexColumn(Array(rows.dropFirst(2)), isWide: isWide, fontSize: fontSize) + } + } + } + + private func hexColumn( + _ repeaters: [MeshMapperActivityAttributes.HeardRepeater], + isWide: Bool, + fontSize: CGFloat + ) -> some View { + VStack(alignment: .leading, spacing: isWide ? 3 : 2) { + ForEach(repeaters) { repeater in + HStack(spacing: isWide ? 5 : 3) { + Circle() + .fill(repeater.typeColor.map(Color.init) ?? state.outcomeColor) + .frame(width: isWide ? 6 : 5, height: isWide ? 6 : 5) + Text(repeater.id.uppercased()) + .font(.system(size: fontSize, weight: .semibold, design: .monospaced)) + Text(Self.formattedRowSnr(repeater.snr, signed: false)) + .font(.system(size: fontSize, weight: .semibold, design: .monospaced)) + .foregroundStyle(repeater.snrColor.map(Color.init) ?? .secondary) + } + .lineLimit(1) + .fixedSize() + .accessibilityElement(children: .ignore) + .accessibilityLabel(repeater.accessibilitySummary) + } + } + } + + /// `[type dot] [hex] [name] … [SNR]`. + private func namedRow( + _ repeater: MeshMapperActivityAttributes.HeardRepeater, + isWide: Bool + ) -> some View { + HStack(spacing: isWide ? 6 : 4) { + Circle() + .fill(repeater.typeColor.map(Color.init) ?? state.outcomeColor) + .frame(width: isWide ? 6 : 5, height: isWide ? 6 : 5) + Text(repeater.id.uppercased()) + .font(.system(size: isWide ? 13 : 10, weight: .semibold, design: .monospaced)) + .fixedSize() + if let name = repeater.resolvedName { + Text(name) + .font(.system(size: isWide ? 12 : 9)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + // The one element that gives way when the row runs out of width. + .layoutPriority(-1) + } + Spacer(minLength: 4) + Text(Self.formattedRowSnr(repeater.snr, signed: isWide)) + .font(.system(size: isWide ? 13 : 10, weight: .semibold, design: .monospaced)) + .foregroundStyle(repeater.snrColor.map(Color.init) ?? .secondary) + .fixedSize() + } + .lineLimit(1) + .accessibilityElement(children: .ignore) + .accessibilityLabel(repeater.accessibilitySummary) + } + + /// No unit, as on the map panel: the column is all SNR, and "dB" on every + /// row costs width the name needs. The narrow card also drops the plus sign. + private static func formattedRowSnr(_ snr: Double, signed: Bool) -> String { + let value = snr.formatted(.number.precision(.fractionLength(1))) + return signed && snr >= 0 ? "+\(value)" : value + } +} + +/// The phase as a caption, a native countdown, and system-drawn progress. +/// +/// **Nothing here animates and nothing here holds state.** It used to: a +/// `@State` fraction was driven to zero by a `withAnimation` lasting the whole +/// phase, which a Live Activity cannot honour. Widgets cap a custom animation +/// at two seconds and run none at all under reduced luminance, so on an +/// always-on lock screen the fill simply stayed wherever the last ActivityKit +/// update left it — observed still nearly full with nine seconds on the clock. +/// +/// Both elements are now derived from absolute dates by the system, so every +/// render the system chooses to make, on whatever schedule it likes, lands in +/// the right place — and none of them costs this extension any work. +private struct MeshMapperPhaseBar: View { + let state: MeshMapperActivityAttributes.ContentState + let titleFont: Font + let countdownFont: Font + let countdownWidth: CGFloat + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Text(state.phaseTitle) + .font(titleFont) + .foregroundStyle(.white.opacity(state.phaseDeadlineHasLapsed ? 0.45 : 1)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 4) + // Fixed width because the native timer otherwise reserves room for its + // widest possible value and starves the title. + MeshMapperCountdown(state: state, font: countdownFont) + .frame(width: countdownWidth, alignment: .trailing) + } + MeshMapperPhaseProgress(state: state) + } + } +} + +/// Elapsed phase progress, drawn by the system from the phase's own dates. +/// +/// The range is the **whole phase**, not `now...deadline`: a range beginning at +/// the current render would reset the bar to empty every time the system +/// redrew the activity, which is the failure the hand-animated fill had in a +/// different form. +/// +/// It fills rather than drains, which is the trade for handing the work to +/// `ProgressView`. The countdown beside it remains the authoritative statement +/// of time remaining; the bar is the glanceable one. +private struct MeshMapperPhaseProgress: View { + let state: MeshMapperActivityAttributes.ContentState + + var body: some View { + if let range = state.phaseProgressRange { + ProgressView(timerInterval: range, countsDown: false) { + EmptyView() + } currentValueLabel: { + EmptyView() + } + .progressViewStyle(.linear) + // The bar wears the running mode's identity color, phone-resolved from + // the same ping palette as the app's markers (so CVD palettes carry + // over). Outcome still lives in the dots: a failed ping must not turn + // the whole track red, which is why this is mode, not result. + .tint(state.modeColor.map(Color.init) ?? MeshMapperPalette.accent) + } else { + // A durable state — disconnected, stopped, waiting for GPS — has no + // deadline to draw. The empty track keeps the block's height fixed, so + // arriving at one cannot reflow everything below it. + Capsule() + .fill(.white.opacity(0.16)) + .frame(height: Self.trackHeight) + } + } + + /// Matches the linear `ProgressView` track this stands in for. + private static let trackHeight: CGFloat = 4 +} + +/// The app mark for the watch band. Falls back to a drawn monogram when the +/// asset cannot load, so the slot never renders empty. +private struct MeshMapperLogoMark: View { + let size: CGFloat + + var body: some View { + if let image = UIImage(named: "LiveActivityLogo") { + Image(uiImage: image) + .resizable() + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: size * 0.24, style: .continuous)) + } else { + RoundedRectangle(cornerRadius: size * 0.24, style: .continuous) + .fill(MeshMapperPalette.accent) + .frame(width: size, height: size) + .overlay { + Text("MM") + .font(.system(size: size * 0.42, weight: .heavy)) + .foregroundStyle(.white) + } + } + } +} + +private struct MeshMapperStatusLabel: View { + let state: MeshMapperActivityAttributes.ContentState + let isStale: Bool + + var body: some View { + Label( + isStale ? "Update delayed" : state.zoneCode ?? state.connectionLabel, + systemImage: isStale + ? "exclamationmark.triangle.fill" + : state.isConnected + ? "antenna.radiowaves.left.and.right" + : "wifi.slash" + ) + .font(.caption2.weight(.semibold)) + .lineLimit(1) + .foregroundStyle(isStale || !state.isConnected ? Color.orange : .secondary) + } +} + +private struct MeshMapperRepeaterSummary: View { + let state: MeshMapperActivityAttributes.ContentState + let limit: Int + let showsNames: Bool + let rowFont: Font + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 5) { + Text(state.repeatersAreCurrent ? "HEARD NOW" : "LAST HEARD") + .font(.caption2.weight(.bold)) + .tracking(0.6) + if state.totalHeardCount > 0 { + Text("\(state.totalHeardCount)") + .font(.caption2.monospacedDigit().weight(.semibold)) + } + } + .foregroundStyle(.secondary) + + if state.repeaters.isEmpty { + HStack(spacing: 6) { + // A failed ping produces no rows, so its colour needs its own mark; + // absence alone cannot distinguish failure from no attempt yet. + MeshMapperOutcomeDot(state: state, diameter: 6) + Text(state.repeaterEmptyLabel) + .font(rowFont) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } else { + ForEach(state.repeaters.prefix(limit)) { repeater in + MeshMapperRepeaterRow( + repeater: repeater, + fallbackColor: state.outcomeColor, + showsName: showsNames, + font: rowFont + ) + } + } + } + } +} + +private struct MeshMapperRepeaterRow: View { + let repeater: MeshMapperActivityAttributes.HeardRepeater + let fallbackColor: Color + let showsName: Bool + let font: Font + + var body: some View { + HStack(spacing: 6) { + Circle() + .fill(repeater.typeColor.map(Color.init) ?? fallbackColor) + .frame(width: 6, height: 6) + .accessibilityHidden(true) + // The path hash remains primary even when a friendly name resolves: it + // is the observation's identity and is what the map overlay names. + Text(repeater.id.uppercased()) + .font(font.monospaced().weight(.semibold)) + .lineLimit(1) + if showsName, let name = repeater.resolvedName { + Text(name) + .font(font) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer(minLength: 4) + Text(repeater.snr.formattedSnr) + .font(font.monospacedDigit().weight(.semibold)) + .foregroundStyle(repeater.snrColor.map(Color.init) ?? .secondary) + .lineLimit(1) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(repeater.accessibilitySummary) + } +} + +private struct MeshMapperMetrics: View { + let state: MeshMapperActivityAttributes.ContentState + let compact: Bool + /// Both defaulted so the lock screen and the island keep what they had. The + /// watch card overrides them to share a line with the heard-list summary, + /// where the queue is the first thing worth giving up for the room. + var font: Font = .caption2.monospacedDigit().weight(.medium) + var showsQueue: Bool = true + + var body: some View { + HStack(spacing: compact ? 9 : 11) { + Text("\(state.primaryMetricLabel) \(state.primaryMetricValue)") + Text("RX \(state.rxCount)") + if showsQueue, state.queueSize > 0 { + Label("\(state.queueSize)", systemImage: "arrow.triangle.2.circlepath") + } + } + .font(font) + .foregroundStyle(.secondary) + .lineLimit(1) + } +} + +private struct MeshMapperIslandBottom: View { + let state: MeshMapperActivityAttributes.ContentState + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + MeshMapperPhaseBar( + state: state, + titleFont: .caption.weight(.semibold), + countdownFont: .caption2.monospacedDigit().weight(.bold), + countdownWidth: 42 + ) + MeshMapperRepeaterSummary( + state: state, + limit: 2, + showsNames: false, + rowFont: .caption2 + ) + MeshMapperMetrics(state: state, compact: true) + } + } +} + +private struct MeshMapperModeBadge: View { + let mode: String + + var body: some View { + Label(mode.uppercased(), systemImage: "antenna.radiowaves.left.and.right") + .font(.caption2.weight(.bold)) + .tracking(0.6) + .foregroundStyle(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + // Mode is categorical, not signal data. Keeping its badge neutral leaves + // palette-resolved outcome, type, and SNR colours unambiguous. + .background(.white.opacity(0.16), in: Capsule()) + } +} + +private struct MeshMapperIslandOutcome: View { + let state: MeshMapperActivityAttributes.ContentState + let isStale: Bool + + var body: some View { + if isStale { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .accessibilityLabel("Update delayed") + } else { + HStack(spacing: 5) { + MeshMapperOutcomeDot(state: state, diameter: 8) + Text(state.primaryMetricValue.formatted()) + .font(.caption.monospacedDigit().weight(.semibold)) + } + } + } +} + +private struct MeshMapperOutcomeDot: View { + let state: MeshMapperActivityAttributes.ContentState + let diameter: CGFloat + + var body: some View { + Circle() + .fill(state.outcomeColor) + .frame(width: diameter, height: diameter) + .accessibilityLabel("Latest ping result") + } +} + +/// **No state and no task**, for the reason `MeshMapperPhaseBar` states above: +/// WidgetKit renders these views out of process and the extension "is not +/// continually active, even if the widget is onscreen", so a `@State` flag +/// retired by a sleeping `Task.sleep` is a promise this context cannot keep. +/// +/// Nothing is lost by dropping it. `activeCountdownRange` is evaluated on every +/// render and already returns nil once the deadline passes, so the branch below +/// falls through to the SNR or the outcome dot exactly when it should — using +/// the clock the system re-reads for us rather than a flag we hoped to update. +private struct MeshMapperCompactTrailing: View { + let state: MeshMapperActivityAttributes.ContentState + + var body: some View { + if state.activeCountdownRange != nil { + MeshMapperCountdown( + state: state, + isActive: true, + font: .caption2.monospacedDigit().weight(.bold) + ) + .frame(minWidth: 28) + } else if let best = state.repeaters.first { + Text(best.snr.formattedSnr) + .font(.caption2.monospacedDigit().weight(.bold)) + .foregroundStyle(best.snrColor.map(Color.init) ?? .primary) + .accessibilityLabel("Best SNR \(best.snr.formattedSnr)") + } else { + MeshMapperOutcomeDot(state: state, diameter: 8) + } + } +} + +private struct MeshMapperCountdown: View { + let state: MeshMapperActivityAttributes.ContentState + var isActive: Bool = true + let font: Font + + var body: some View { + if isActive, let range = state.activeCountdownRange { + Text(timerInterval: range, countsDown: true, showsHours: false) + .font(font) + .lineLimit(1) + .accessibilityLabel("Time remaining") + } + } +} + +private enum MeshMapperPalette { + /// slate-900, the app's main dark background. + static let background = Color(red: 0.059, green: 0.090, blue: 0.165) + /// slate-800, the app's card and panel surface, used for the small card's band. + static let surface = Color(red: 0.118, green: 0.161, blue: 0.231) + /// slate-700, the app's border colour, used for the band's divider. + static let hairline = Color(red: 0.200, green: 0.255, blue: 0.333) + /// emerald-600, the app's primary. + static let accent = Color(red: 0.020, green: 0.588, blue: 0.412) +} + +extension MeshMapperActivityAttributes.ContentState { + fileprivate var activeCountdownRange: ClosedRange? { + let now = Date() + guard let phaseEndsAt, phaseEndsAt > now else { return nil } + return now...phaseEndsAt + } + + /// The phase's own span, for a `ProgressView` that reads the clock itself. + /// + /// Deliberately anchored to the phase's start rather than to `now`, so a + /// system-initiated redraw resumes the bar where the clock says it is + /// instead of restarting it. + fileprivate var phaseProgressRange: ClosedRange? { + guard let phaseEndsAt, let phaseDurationMs, phaseDurationMs > 0 else { + return nil + } + let start = phaseEndsAt.addingTimeInterval(-Double(phaseDurationMs) / 1000) + guard start < phaseEndsAt else { return nil } + return start...phaseEndsAt + } + + /// Evaluated at render time rather than retired by a sleeping task: a widget + /// that is not being redrawn has no one to show the change to, and the phase + /// transition that follows a deadline arrives as an urgent update anyway. + fileprivate var phaseDeadlineHasLapsed: Bool { + guard let phaseEndsAt else { return false } + return phaseEndsAt <= Date() + } + + fileprivate var connectionLabel: String { + isConnected ? "Connected" : "Disconnected" + } + + fileprivate var primaryMetricLabel: String { + switch mode.lowercased() { + case "passive": return "DISC" + case "trace": return "TRACE" + default: return "TX" + } + } + + fileprivate var primaryMetricValue: Int { + switch mode.lowercased() { + case "passive": return discoveryCount + case "trace": return traceCount + default: return txCount + } + } + + fileprivate var repeaterEmptyLabel: String { + switch phase { + case "listening", "listening_discovery", "listening_trace": + return "Nothing heard" + default: + return "Nothing heard in the last cycle" + } + } + + /// The phase title without its trailing ellipsis, for the small card's + /// band: the countdown beside it already says "in progress", and the dots + /// are what pushed the longest title into truncating on tight cards. + fileprivate var bandPhaseTitle: String { + var trimmed = phaseTitle + while trimmed.hasSuffix("\u{2026}") || trimmed.hasSuffix(".") { + trimmed.removeLast() + } + return trimmed.isEmpty ? phaseTitle : trimmed + } + + /// One word of state for the watch band, where the full title has no room + /// and a countdown-less "Next ping" reads as a question. Waiting phases say + /// what just happened instead of promising a time. + fileprivate var narrowStatusWord: String { + switch phase { + case "sending": return "Sending" + case "discovering": return "Discovery" + case "tracing": return "Tracing" + case "listening", "listening_discovery", "listening_trace": return "Listening" + case "waiting", "waiting_discovery", "waiting_trace", "cooldown": return "Sent" + case "skipped": return "Skipped" + case "waiting_for_gps": return "No GPS" + case "paused_outside_zone": return "Paused" + case "disconnected": return "Offline" + case "tx_blocked": return "Blocked" + case "stopping", "stopped": return "Stopped" + case "starting": return "Starting" + default: return bandPhaseTitle + } + } + + /// The app's own per-mode icon as an SF Symbol: swap arrows for Hybrid, + /// a paperplane for Active's send, an ear for Passive, a scope for Trace. + fileprivate var modeSymbol: String { + switch mode.lowercased() { + case "hybrid": return "arrow.left.arrow.right" + case "passive": return "ear" + case "trace", "targeted": return "scope" + default: return "paperplane.fill" + } + } + + fileprivate var phaseSymbol: String { + switch phase { + case "sending": return "arrow.up.circle.fill" + case "discovering": return "dot.radiowaves.left.and.right" + case "tracing": return "scope" + case "listening", "listening_discovery", "listening_trace": return "waveform" + case "waiting", "waiting_discovery", "waiting_trace", "cooldown": return "timer" + case "skipped": return "forward.end.fill" + case "stopping", "stopped": return "stop.circle.fill" + case "waiting_for_gps": return "location.slash.fill" + case "paused_outside_zone": return "map.fill" + case "disconnected": return "wifi.slash" + case "tx_blocked": return "nosign" + case "starting": return "hourglass" + default: return "antenna.radiowaves.left.and.right" + } + } + + /// Phone-resolved outcome colour wins whenever one exists. The phase colour + /// is only a fallback before a session has produced a ping result. + fileprivate var outcomeColor: Color { + if let pingColor { return Color(pingColor) } + return phaseColor + } + + /// Colour for an element that describes the current phase rather than the + /// result of the most recent ping. + fileprivate var phaseColor: Color { + switch phase { + case "sending", "discovering", "tracing": return .blue + case "listening", "listening_discovery", "listening_trace": return .teal + case "waiting", "waiting_discovery", "waiting_trace", "cooldown": return .cyan + case "skipped", "waiting_for_gps", "paused_outside_zone": return .orange + case "disconnected", "tx_blocked": return .red + case "stopped": return .gray + default: return .white + } + } +} + +extension MeshMapperActivityAttributes.HeardRepeater { + fileprivate var resolvedName: String? { + let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty, trimmed.caseInsensitiveCompare(id) != .orderedSame else { + return nil + } + return trimmed + } + + fileprivate var accessibilitySummary: String { + let identity = resolvedName.map { "\(id), \($0)" } ?? id + return "\(identity), SNR \(snr.formattedSnr)" + } +} + +extension MeshMapperActivityAttributes.ResolvedColor { + fileprivate var swiftUIColor: Color { + Color(.sRGB, red: r, green: g, blue: b, opacity: 1) + } +} + +extension Color { + fileprivate init(_ resolved: MeshMapperActivityAttributes.ResolvedColor) { + self = resolved.swiftUIColor + } +} + +extension Double { + fileprivate var formattedSnr: String { + let sign = self >= 0 ? "+" : "" + return "\(sign)\(formatted(.number.precision(.fractionLength(1)))) dB" + } +} + +#if DEBUG +extension MeshMapperActivityAttributes.ContentState { + fileprivate static var previewRunningWithResponses: Self { + Self( + mode: "Hybrid", + phase: "listening", + phaseTitle: "Listening…", + phaseDetail: "Waiting for repeater echoes", + phaseEndsAt: Date().addingTimeInterval(42), + phaseDurationMs: 60_000, + pingColor: .init(r: 0.20, g: 0.84, b: 0.45), + modeColor: .init(r: 0.30, g: 0.69, b: 0.31), + isConnected: true, + zoneCode: "SEA", + noiseFloorDbm: -102, + companionBatteryPct: 80, + txCount: 42, + rxCount: 318, + discoveryCount: 8, + traceCount: 0, + queueSize: 2, + repeaters: [ + .init( + id: "A61F", + name: "Capitol Hill", + snr: 12.4, + typeColor: .init(r: 0.20, g: 0.84, b: 0.45), + snrColor: .init(r: 0.34, g: 0.90, b: 0.44) + ), + .init( + id: "0B73", + name: "Lake Union", + snr: 2.1, + typeColor: .init(r: 0.22, g: 0.80, b: 0.78), + snrColor: .init(r: 0.96, g: 0.76, b: 0.22) + ), + .init( + id: "91CE", + name: nil, + snr: -8.7, + typeColor: .init(r: 0.64, g: 0.42, b: 0.94), + snrColor: .init(r: 0.94, g: 0.30, b: 0.28) + ), + // A fourth, because the Smart Stack card draws two columns by two rows + // and a three-row sample cannot show the grid it has to fit. + .init( + id: "5E02", + name: "Beacon Hill", + snr: 6.8, + typeColor: .init(r: 0.20, g: 0.84, b: 0.45), + snrColor: .init(r: 0.34, g: 0.90, b: 0.44) + ), + ], + totalHeardCount: 5, + repeatersAreCurrent: true, + updatedAt: Date() + ) + } + + fileprivate static var previewRunningWithNoneHeard: Self { + Self( + mode: "Active", + phase: "listening", + phaseTitle: "Listening…", + phaseDetail: "Waiting for repeater echoes", + phaseEndsAt: Date().addingTimeInterval(25), + phaseDurationMs: 60_000, + pingColor: .init(r: 0.94, g: 0.28, b: 0.25), + modeColor: .init(r: 0.30, g: 0.69, b: 0.31), + isConnected: true, + zoneCode: "SEA", + noiseFloorDbm: -96, + companionBatteryPct: 15, + txCount: 17, + rxCount: 3, + discoveryCount: 0, + traceCount: 0, + queueSize: 0, + repeaters: [], + totalHeardCount: 0, + repeatersAreCurrent: true, + updatedAt: Date() + ) + } + + fileprivate static var previewGridMode: Self { + var state = previewRunningWithResponses + state.showRepeaterNames = false + // The widest rows the grid can be handed: 6-character IDs from 3-byte + // path mode with double-digit negative SNRs. The solver must fit these + // on a 40 mm card without clipping. + state.repeaters = state.repeaters.enumerated().map { index, repeater in + MeshMapperActivityAttributes.HeardRepeater( + id: repeater.id + String(format: "%02X", index), + name: repeater.name, + snr: index == 2 ? -12.4 : repeater.snr, + typeColor: repeater.typeColor, + snrColor: repeater.snrColor + ) + } + return state + } + + fileprivate static var previewLapsedDeadline: Self { + var state = previewRunningWithResponses + state.phaseEndsAt = Date().addingTimeInterval(-5) + state.repeatersAreCurrent = false + return state + } +} + +private struct MeshMapperActivityPreview: View { + let state: MeshMapperActivityAttributes.ContentState + + var body: some View { + ScrollView { + VStack(spacing: 18) { + MeshMapperLockScreenContent(state: state, isStale: false) + .background(MeshMapperPalette.background) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + + // The island's regions are composed by the system at runtime. Showing + // their shared bottom region here keeps its bar and rows inspectable + // without requiring a BLE-backed ActivityKit session. + MeshMapperIslandBottom(state: state) + .padding(12) + .foregroundStyle(.white) + .background(.black) + .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) + + if #available(iOSApplicationExtension 18.0, *) { + // Both ends of the watch range, because the card's row solver and + // its `ViewThatFits` ladder can only be reviewed against real sizes. + ForEach(MeshMapperSmartStackSize.allCases, id: \.self) { size in + MeshMapperSmallActivityContent(state: state, isStale: false) + .frame(width: size.width, height: size.height) + .background(MeshMapperPalette.background) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + } + } + .padding() + } + .preferredColorScheme(.dark) + } +} + +/// The Smart Stack card at both ends of the watch range, from the HIG's +/// watchOS widget dimensions. A card this view is reviewed in must be given a +/// height: overflowing it is the defect being guarded against, and a harness +/// that lets the content pick its own height cannot show that. +enum MeshMapperSmartStackSize: CaseIterable { + /// 40 mm — the smallest card, and the one the ladder must still fit. + case small + /// 49 mm — the largest, where the summary and mode lines earn their place. + case large + + var label: String { + switch self { + case .small: return "40mm" + case .large: return "49mm" + } + } + + var width: CGFloat { + switch self { + case .small: return 152 + case .large: return 191 + } + } + + var height: CGFloat { + switch self { + case .small: return 69.5 + case .large: return 81.5 + } + } +} + +/// Renders each layout to a PNG so the design can be reviewed without a live +/// session. +/// +/// A Live Activity needs ActivityKit to construct its context, and ActivityKit +/// needs a session, which needs BLE — which the simulator does not have. That +/// left three layouts reviewable only on a wrist. The content views take a +/// plain `ContentState`, so `ImageRenderer` can draw them headlessly instead. +/// +/// **`ImageRenderer` cannot draw the phase progress bar, and its failure looks +/// like a bug in the bar.** A `ProgressView(timerInterval:)` comes out as a +/// full-width track with a no-entry glyph in the middle, identically at every +/// remaining fraction — `Text(timerInterval:)` beside it snapshots fine, which +/// makes the difference easy to misread as ours. Hosting the same views in a +/// live `UIHostingController` in the simulator draws them correctly and +/// advances them with no state update at all: 0:40 of a 60 s phase at 33 %, +/// 0:23 at 62 %, 0:05 at 92 %. Review the bar that way, not through here. +/// +/// DEBUG-only, and reached solely from a launch argument. +enum MeshMapperActivityRenderHarness { + /// Sizes are the real ones: a Live Activity on the lock screen spans about + /// 360 pt, the island's shared bottom region about 360, and the watch's small + /// family a Smart Stack card — whose height is the constraint, so it is given + /// rather than left to the content to choose. + @MainActor + static func writeRenders(to directory: URL) -> [String] { + let states: [(String, MeshMapperActivityAttributes.ContentState)] = [ + ("responses", .previewRunningWithResponses), + ("none-heard", .previewRunningWithNoneHeard), + ("lapsed", .previewLapsedDeadline), + ("grid", .previewGridMode), + ] + + var written: [String] = [] + for (name, state) in states { + written += [ + render( + MeshMapperLockScreenContent(state: state, isStale: false) + .frame(width: 360) + .background(MeshMapperPalette.background), + named: "lock-\(name)", in: directory + ), + render( + MeshMapperIslandBottom(state: state) + .padding(12) + .foregroundStyle(.white) + .frame(width: 360) + .background(.black), + named: "island-\(name)", in: directory + ), + ].compactMap { $0 } + + if #available(iOS 18.0, *) { + for size in MeshMapperSmartStackSize.allCases { + if let path = render( + MeshMapperSmallActivityContent(state: state, isStale: false) + .frame(width: size.width, height: size.height) + .background(MeshMapperPalette.background), + named: "small-\(size.label)-\(name)", in: directory + ) { + written.append(path) + } + } + // The wide layouts CarPlay renders; the exact CarPlay size is not + // published, so these bracket the threshold: icon-only mode at 250, + // the spelled-out mode word at 320. + for width in [CGFloat(250), CGFloat(320)] { + if let path = render( + MeshMapperSmallActivityContent(state: state, isStale: false) + .frame(width: width, height: 100) + .background(MeshMapperPalette.background), + named: "small-carplay\(Int(width))-\(name)", in: directory + ) { + written.append(path) + } + } + } + } + return written + } + + @MainActor + private static func render( + _ view: some View, named name: String, in directory: URL + ) -> String? { + let renderer = ImageRenderer(content: view.environment(\.colorScheme, .dark)) + renderer.scale = 3 + guard let data = renderer.uiImage?.pngData() else { return nil } + let url = directory.appendingPathComponent("\(name).png") + try? data.write(to: url) + return url.path + } +} + +#Preview("Running — responses") { + MeshMapperActivityPreview(state: .previewRunningWithResponses) +} + +#Preview("Running — none heard") { + MeshMapperActivityPreview(state: .previewRunningWithNoneHeard) +} + +#Preview("Lapsed deadline") { + MeshMapperActivityPreview(state: .previewLapsedDeadline) +} + +#Preview("Grid mode (names off)") { + MeshMapperActivityPreview(state: .previewGridMode) +} +#endif diff --git a/ios/MeshMapperLiveActivity/MeshMapperLiveActivityBundle.swift b/ios/MeshMapperLiveActivity/MeshMapperLiveActivityBundle.swift new file mode 100644 index 00000000..08195fee --- /dev/null +++ b/ios/MeshMapperLiveActivity/MeshMapperLiveActivityBundle.swift @@ -0,0 +1,9 @@ +import SwiftUI +import WidgetKit + +@main +struct MeshMapperLiveActivityBundle: WidgetBundle { + var body: some Widget { + MeshMapperLiveActivity() + } +} diff --git a/ios/MeshMapperWatch/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/MeshMapperWatch/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..6e000ca8 --- /dev/null +++ b/ios/MeshMapperWatch/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xC7", + "green" : "0x54", + "red" : "0x7D" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..88819d5d --- /dev/null +++ b/ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "Icon-Watch-1024x1024.png", + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Icon-Watch-1024x1024.png b/ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Icon-Watch-1024x1024.png new file mode 100644 index 00000000..ee5cc0cd Binary files /dev/null and b/ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Icon-Watch-1024x1024.png differ diff --git a/ios/MeshMapperWatch/Assets.xcassets/Contents.json b/ios/MeshMapperWatch/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/ios/MeshMapperWatch/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/MeshMapperWatch/ContentView.swift b/ios/MeshMapperWatch/ContentView.swift new file mode 100644 index 00000000..f8b67a9a --- /dev/null +++ b/ios/MeshMapperWatch/ContentView.swift @@ -0,0 +1,75 @@ +import SwiftUI + +/// Root shell. +/// +/// The map is always page one and full-bleed, with controls immediately after +/// it. Where the heard-node list lives is a presentation choice — a sheet over +/// the map, or its own page — driven by `WatchSettings.nodeListPlacement`. +/// Both read the same `NodeListView`, so this is a toggle rather than two +/// implementations. +struct ContentView: View { + @Environment(WatchSettings.self) private var settings + + @State private var selection = Self.requestedInitialPage + + /// Page to open on, so one can be captured headlessly for design review — + /// the simulator offers no way to swipe. + /// + /// Supplied as the state's initial value rather than assigned in `onAppear`. + /// A `.verticalPage` `TabView` ignores a selection change made that late, so + /// the assignment silently did nothing and every capture landed on the map. + private static var requestedInitialPage: Int { + #if DEBUG + return max(0, UserDefaults.standard.integer(forKey: "MeshMapperInitialPage")) + #else + return 0 + #endif + } + + var body: some View { + // A vertical-page TabView already installs watchOS's per-page navigation + // hosting. Putting another NavigationStack inside one of those pages nests + // wrapped controllers and aborts in PUICStackedNavigationBar.layoutSubviews. + // Keep the app's one stack outside the pager so toolbar items have a host + // without making any individual page create a second one. + NavigationStack { + TabView(selection: $selection) { + MapPage(isSelected: selection == 0).tag(0) + ControlsPage().tag(1) + + // The sheet placement is opened by tapping the map's status panel, which + // the readout does not have — so with Readout selected, honouring "sheet" + // would leave the heard list with no way in at all. A choice of main page + // must not make a feature unreachable, so the page appears regardless. + if settings.nodeListPlacement == .page || settings.mainPageContent == .readout { + NodeListView() + .navigationTitle("Heard") + .navigationBarTitleDisplayMode(.inline) + .tag(2) + } + + #if DEBUG + // This page exposes raw, low-friction verification controls. Keeping + // it out of Release is a transmit-safety boundary, not page polish. + DebugPage().tag(3) + #endif + SettingsPage().tag(4) + } + .tabViewStyle(.verticalPage) + #if DEBUG + /// Drive a page change, because the simulator offers no way to swipe. + /// + /// Page transitions are a real defect surface here — a sheet raised from + /// the map once survived onto the next page, where it blurred that page + /// and swallowed all input, which reads exactly like a crash. This makes + /// that class reproducible without a wrist. + .task { + let target = UserDefaults.standard.integer(forKey: "MeshMapperAutoPageTo") + guard target > 0 else { return } + try? await Task.sleep(for: .seconds(5)) + selection = target + } + #endif + } + } +} diff --git a/ios/MeshMapperWatch/ControlsPage.swift b/ios/MeshMapperWatch/ControlsPage.swift new file mode 100644 index 00000000..31759624 --- /dev/null +++ b/ios/MeshMapperWatch/ControlsPage.swift @@ -0,0 +1,325 @@ +import SwiftUI + +/// Session controls sized for deliberate use while moving. +/// +/// Availability is only the phone's last-reported state. The watch does not +/// duplicate transport, GPS, cooldown, or session guards; every tap still goes +/// to the phone, which revalidates it and returns the reason when refused. +struct ControlsPage: View { + @Environment(WatchSessionClient.self) private var client + @Environment(WatchSettings.self) private var settings + + @State private var pingArmed = false + @State private var disarmPingTask: Task? + + private static let minimumTapHeight: CGFloat = 44 + private static let compactLabelHeight: CGFloat = 24 + + /// The explanatory text scales with the wearer's text-size setting; the + /// controls around it do not. + /// + /// **`Font.system(size:)` is a fixed point size and ignores Dynamic Type + /// entirely**, so the page-level `dynamicTypeSize` clamp that used to sit + /// here bounded nothing — every string on this page was 10 or 13 points at + /// every setting, including the largest accessibility sizes. `@ScaledMetric` + /// keeps the tuned 10 pt base and makes it grow, which is the whole point: + /// `blockedReason` is the one thing that explains a dead button, and at + /// 10 pt in 45 % white it was the least legible text in the app. + /// + /// Deliberately unbounded, following `NodeListView`: fewer rows at a large + /// setting is the correct outcome, and this page already scrolls. The + /// buttons keep their fixed labels so the hardware-checked 44 pt targets and + /// single-line titles survive — a narrower deviation, and a stated one. + @ScaledMetric(relativeTo: .caption2) private var reasonFontSize: CGFloat = 10 + + private var controls: WatchControls? { client.snapshot?.controls } + + private var effectiveStartMode: WatchSettings.DefaultStartMode { + settings.effectiveStartMode( + availableStartModes: client.snapshot?.availableStartModes + ) + } + + /// The manual-ping cooldown as a native timer range, if one is still ahead. + /// + /// One `now` for both bounds. Reading `Date()` to test the deadline and again + /// to build the range let a deadline crossing between the two produce + /// `lower > upper`, which traps at runtime. MapPage's `activeCountdownRange` + /// documents and avoids the same hazard. + private var cooldownCountdownRange: ClosedRange? { + guard let ms = controls?.manualCooldownEndsAtMs else { return nil } + let now = Date() + let endsAt = Date(timeIntervalSince1970: ms / 1000) + guard endsAt > now else { return nil } + return now...endsAt + } + + var body: some View { + ScrollView { + // No page title. The buttons name themselves, and on a 40 mm screen a + // header pushed `blockedReason` — the one thing that explains a dead + // button — below the fold, which is the opposite of what it is for. The + // compact in-card context adds meaning without spending a navigation row. + VStack(spacing: 5) { + sessionHeader + startStopControl + manualPingControl + + // A fresh cue retained across a watch-process restart has no local tap + // to name. Keep that rare but valid feedback visible without guessing + // which control owns it. + if client.lastRefusalCommand == nil, + let refusal = client.lastRefusal + { + refusalMessage(refusal) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 5) + .background( + .ultraThinMaterial, + in: .rect(cornerRadius: WatchPalette.cornerRadius, style: .continuous) + ) + .overlay( + RoundedRectangle( + cornerRadius: WatchPalette.cornerRadius, + style: .continuous + ) + .stroke(.white.opacity(0.12), lineWidth: 0.5) + ) + .padding(.horizontal, 6) + .padding(.top, 2) + // The last line must not finish against the bottom edge: the display + // curves there, and the simulator's flat rectangle has hidden exactly + // this twice before. + .padding(.bottom, 14) + } + // No page-level type clamp. It read as protecting the tap targets, but the + // controls it was protecting use fixed point sizes and were never at risk; + // all it actually did was cap `reasonFontSize` at `.large` and stop the one + // piece of reading content on the page from reaching accessibility sizes. + .opacity(client.isStale ? 0.5 : 1.0) + .onChange(of: controls?.canManualPing) { _, canManualPing in + if canManualPing != true { disarmPing() } + } + .onDisappear { disarmPing() } + } + + private var sessionHeader: some View { + HStack(alignment: .firstTextBaseline, spacing: 0) { + // The mode doubles as the compact section label. A separate "SESSION" + // row would spend the exact vertical space that keeps a two-line blocked + // reason visible on the 40 mm display. + Text(controls?.isSessionActive == true + ? (client.snapshot?.mode.uppercased() ?? "SESSION") + : "IDLE") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.white.opacity(0.55)) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + + Spacer(minLength: 4) + + Text(client.snapshot?.phaseTitle ?? "Waiting for iPhone") + // Phase titles are prose, not an identity or numeric value. The + // proportional face plus bounded scaling keeps even "Device + // disconnected" and "Listening for trace…" intact on 40 mm without + // buying that width by pushing the blocked reason below the fold. + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.6) + .allowsTightening(true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var startStopControl: some View { + VStack(alignment: .leading, spacing: 2) { + startStopButton + if controls?.canStartStop != true, + let reason = controls?.blockedReason + { + controlReason(reason) + } + if let refusal = startStopRefusal { + refusalMessage(refusal) + } + } + } + + private var manualPingControl: some View { + VStack(alignment: .leading, spacing: 2) { + manualPingButton + // When Start/Stop is also unavailable, its action owns the shared reason + // above. Otherwise keep the explanation immediately under Manual ping. + if controls?.canStartStop == true, + controls?.canManualPing != true, + let reason = controls?.blockedReason + { + controlReason(reason) + } + if client.lastRefusalCommand == .manualPing, + let refusal = client.lastRefusal + { + refusalMessage(refusal) + } + } + } + + private var startStopRefusal: String? { + switch client.lastRefusalCommand { + case .startSession, .stopSession: + return client.lastRefusal + default: + return nil + } + } + + private func controlReason(_ reason: String) -> some View { + Text(reason) + .font(.system(size: reasonFontSize, weight: .medium)) + .foregroundStyle(.white.opacity(0.45)) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + } + + private func refusalMessage(_ refusal: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Image(systemName: "exclamationmark.circle.fill") + Text(refusal) + } + .font(.system(size: reasonFontSize, weight: .medium)) + .foregroundStyle(WatchPalette.armed) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + } + + private var startStopButton: some View { + let isActive = controls?.isSessionActive ?? false + let kind: WatchCommand.Kind = isActive ? .stopSession : .startSession + let isPending = client.pendingCommand == kind + let isEnabled = controls?.canStartStop == true && !isPending + let startTitle = "Start \(effectiveStartMode.label)" + let buttonTitle = isPending + ? (isActive ? "Stopping…" : "Starting…") + : (isActive ? "Stop" : startTitle) + + return Button { + if kind == .startSession { + client.send(kind, mode: effectiveStartMode.rawValue) + } else { + client.send(kind) + } + } label: { + Text(buttonTitle) + .font(.system(size: 13, weight: .semibold)) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, minHeight: Self.compactLabelHeight) + // A ProgressView accepts the horizontal slack offered by a stack. As + // an overlay it can appear without participating in the label's + // centring, so feedback never makes the action jump under a thumb. + .overlay(alignment: .leading) { + if isPending { + ProgressView() + .controlSize(.small) + .frame(width: 16, height: 16) + .padding(.leading, 6) + } + } + } + .buttonStyle(.borderedProminent) + // The prominent watch style supplies substantial chrome outside its + // label. Giving the label the full ergonomic floor made the finished + // target 69.5 pt tall; applying that floor to the styled Button preserves + // the moving-vehicle tap target without paying for it twice. + .frame(minHeight: Self.minimumTapHeight) + .buttonBorderShape(.roundedRectangle(radius: WatchPalette.cornerRadius)) + .tint(isEnabled + ? (isActive ? WatchPalette.stop : WatchPalette.start) + : WatchPalette.disabled) + .disabled(!isEnabled) + } + + private var manualPingButton: some View { + let isPending = client.pendingCommand == .manualPing + let isEnabled = controls?.canManualPing == true && !isPending + + return Button { + if pingArmed { + disarmPing() + client.send(.manualPing) + } else { + armPing() + } + } label: { + pingLabel(isPending: isPending) + .frame(maxWidth: .infinity, minHeight: Self.compactLabelHeight) + // Keep this identical to Start/Stop: pending feedback belongs at the + // edge of the target, not in the row that determines its label's centre. + .overlay(alignment: .leading) { + if isPending { + ProgressView() + .controlSize(.small) + .frame(width: 16, height: 16) + .padding(.leading, 6) + } + } + } + .buttonStyle(.borderedProminent) + .frame(minHeight: Self.minimumTapHeight) + .buttonBorderShape(.roundedRectangle(radius: WatchPalette.cornerRadius)) + .tint(isEnabled + ? (pingArmed ? WatchPalette.armed : WatchPalette.ping) + : WatchPalette.disabled) + .disabled(!isEnabled) + } + + /// The ping button's label, as exactly one view. + /// + /// A `Group` will not do here: with two children it applies each modifier to + /// both, so `maxWidth: .infinity` went to the words *and* the timer and threw + /// them to opposite ends of the button. The pair has to be its own stack to + /// read as one centred label. + /// + /// A cooldown is the one unavailability the phone reports without a + /// `blockedReason`, so without this the button would sit dead and unexplained. + /// The deadline is absolute, so the countdown stays right even if no further + /// snapshot arrives. + @ViewBuilder + private func pingLabel(isPending: Bool) -> some View { + if let range = cooldownCountdownRange { + HStack(spacing: 5) { + Text("Ping in") + Text(timerInterval: range, countsDown: true) + .monospacedDigit() + .frame(width: 38, alignment: .leading) + } + .font(.system(size: 13, weight: .semibold)) + } else { + Text(isPending ? "Sending…" : (pingArmed ? "Send ping?" : "Manual ping")) + .font(.system(size: 13, weight: .semibold)) + } + } + + /// The first tap buys a short confirmation window; expiry returns the button + /// to a harmless state without involving the phone or transmitting anything. + private func armPing() { + disarmPingTask?.cancel() + pingArmed = true + disarmPingTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(3)) + guard !Task.isCancelled else { return } + pingArmed = false + disarmPingTask = nil + } + } + + private func disarmPing() { + disarmPingTask?.cancel() + disarmPingTask = nil + pingArmed = false + } +} diff --git a/ios/MeshMapperWatch/DebugPage.swift b/ios/MeshMapperWatch/DebugPage.swift new file mode 100644 index 00000000..e94a6140 --- /dev/null +++ b/ios/MeshMapperWatch/DebugPage.swift @@ -0,0 +1,193 @@ +import SwiftUI + +#if DEBUG +/// Raw state dump and command buttons. +/// +/// Kept from Phase 2 as a development surface while the real UI is built out. +/// Phase 5 replaces the buttons with the proper controls page; this page goes +/// away once the node list and controls carry their own verification. +struct DebugPage: View { + @Environment(WatchSessionClient.self) private var client + @Environment(WatchSettings.self) private var settings + + @State private var pingArmed = false + @State private var disarmPingTask: Task? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + header + + if client.versionMismatch { + Text("Update MeshMapper on iPhone — wire version mismatch") + .font(.caption2) + .foregroundStyle(.orange) + } + + if let snapshot = client.snapshot { + session(snapshot) + counters(snapshot) + geo(snapshot) + controls(snapshot) + } else { + Text("No snapshot yet") + .font(.caption2) + .foregroundStyle(.secondary) + } + + commandButtons + + if let refusal = client.lastRefusal { + Text(refusal) + .font(.caption2) + .foregroundStyle(.orange) + } + } + .padding(.horizontal, 4) + .opacity(client.isStale ? 0.45 : 1.0) + } + .onDisappear { disarmPing() } + } + + private var header: some View { + HStack { + Circle() + .fill(client.isReachable ? .green : .gray) + .frame(width: 6, height: 6) + Text(client.isReachable ? "Reachable" : "Unreachable") + .font(.caption2) + Spacer() + if let receivedAt = client.receivedAt { + Text(receivedAt, style: .relative) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + } + } + } + + private func session(_ s: WatchSnapshot) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(s.phaseTitle).font(.headline).lineLimit(2) + if let detail = s.phaseDetail { + Text(detail).font(.caption2).foregroundStyle(.secondary) + } + HStack(spacing: 4) { + if let color = s.pingColor { + Circle().fill(Color(color)).frame(width: 8, height: 8) + } + Text(s.mode).font(.caption2) + if let endsAt = s.phaseEndsAt, endsAt > Date() { + Text(timerInterval: Date()...endsAt, countsDown: true) + .font(.caption.monospacedDigit()) + } + } + } + } + + private func counters(_ s: WatchSnapshot) -> some View { + Text("TX \(s.txCount) · RX \(s.rxCount) · DISC \(s.discoveryCount) · Q \(s.queueSize)") + .font(.system(size: 10).monospacedDigit()) + .foregroundStyle(.secondary) + } + + private func geo(_ s: WatchSnapshot) -> some View { + VStack(alignment: .leading, spacing: 2) { + if let you = s.geo.you { + Text(String(format: "%.5f, %.5f", you.lat, you.lon)) + .font(.system(size: 10).monospacedDigit()) + } else { + Text("No GPS fix").font(.system(size: 10)).foregroundStyle(.secondary) + } + Text("pings \(s.geo.pings.count) · rptrs \(s.geo.repeaters.count) · heard \(s.geo.heard.count)") + .font(.system(size: 10).monospacedDigit()) + .foregroundStyle(.secondary) + + ForEach(s.geo.heard) { node in + HStack(spacing: 4) { + Circle().fill(Color(node.typeColor)).frame(width: 5, height: 5) + Text(node.id) + .font(.system(size: 10, design: .monospaced)) + if let name = node.name { + Text(name) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + if let snr = node.snr { + Text(String(format: "%.1f", snr)) + .font(.system(size: 10).monospacedDigit()) + } + } + } + } + } + + private func controls(_ s: WatchSnapshot) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text("start/stop \(s.controls.canStartStop ? "✓" : "✗") · ping \(s.controls.canManualPing ? "✓" : "✗")") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + if let reason = s.controls.blockedReason { + Text(reason).font(.system(size: 10)).foregroundStyle(.secondary) + } + } + } + + private var commandButtons: some View { + VStack(spacing: 4) { + Button(sessionActive ? "Stop" : "Start \(effectiveStartMode.label)") { + if sessionActive { + client.send(.stopSession) + } else { + client.send(.startSession, mode: effectiveStartMode.rawValue) + } + } + .disabled(!(client.snapshot?.controls.canStartStop ?? false)) + + Button(pingArmed ? "Send ping?" : "Manual ping") { + if pingArmed { + disarmPing() + client.send(.manualPing) + } else { + armPing() + } + } + .disabled(!(client.snapshot?.controls.canManualPing ?? false)) + + Button("Refresh") { client.send(.requestSnapshot) } + } + .font(.caption2) + .buttonStyle(.bordered) + } + + private var sessionActive: Bool { + client.snapshot?.controls.isSessionActive ?? false + } + + private var effectiveStartMode: WatchSettings.DefaultStartMode { + settings.effectiveStartMode( + availableStartModes: client.snapshot?.availableStartModes + ) + } + + /// DEBUG still reaches physical development watches, so its raw-state page + /// keeps the same deliberate second tap as every shipping ping surface. + private func armPing() { + disarmPingTask?.cancel() + pingArmed = true + disarmPingTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(3)) + guard !Task.isCancelled else { return } + pingArmed = false + disarmPingTask = nil + } + } + + private func disarmPing() { + disarmPingTask?.cancel() + disarmPingTask = nil + pingArmed = false + } +} +#endif diff --git a/ios/MeshMapperWatch/Info.plist b/ios/MeshMapperWatch/Info.plist new file mode 100644 index 00000000..1e2b71bd --- /dev/null +++ b/ios/MeshMapperWatch/Info.plist @@ -0,0 +1,38 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + MeshMapper + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + MeshMapper + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + WKApplication + + WKCompanionAppBundleIdentifier + $(MESHMAPPER_BUNDLE_PREFIX) + + WKSupportsLiveActivityLaunchAttributeTypes + + WKRunsIndependentlyOfCompanionApp + + + diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift new file mode 100644 index 00000000..e02110c4 --- /dev/null +++ b/ios/MeshMapperWatch/MapPage.swift @@ -0,0 +1,2587 @@ +import CoreLocation +import MapKit +import SwiftUI +import WatchKit + +/// The map, drawn on Apple's basemap. +/// +/// MeshMapper's own basemap cannot come along: `MKTileOverlay` is +/// `API_UNAVAILABLE(watchos)`, so the OpenFreeMap styles, the ArcGIS satellite +/// raster, and the coverage vector tiles have no route onto the wrist. Only +/// the data layer — ping colours, repeater pins, the fix — is MeshMapper's. +/// Apple's `.imagery` also renders pixel-identically to `.standard` here, +/// verified by pixel diff on device and simulator, so the watch exposes no +/// satellite mode. +struct MapPage: View { + /// TabView may retain neighbouring pages. The selected tag is the reliable + /// signal that this page is actually visible; view lifecycle alone cannot + /// tell the phone whether its marker payload is useful. + let isSelected: Bool + + @Environment(WatchSessionClient.self) private var client + @Environment(WatchSettings.self) private var settings + @Environment(\.isLuminanceReduced) private var environmentLuminanceReduced + + /// Always-On cannot be entered in the simulator, and on hardware it needs a + /// wrist-down device — so the dimmed layout was unreviewable. This lets it be + /// captured headlessly, like the sample-data affordances. + private var isLuminanceReduced: Bool { + #if DEBUG + if debugForcedDim { return true } + if UserDefaults.standard.bool(forKey: "MeshMapperForceDimmed") { return true } + #endif + return environmentLuminanceReduced + } + + #if DEBUG + /// Drives a real full-luminance -> dimmed *transition* in the simulator, + /// which `MeshMapperForceDimmed` alone cannot do: that flag only sets the + /// state the page is born into, so it exercises the cold-dimmed path and + /// never the glance path. Everything about the dim hold lives in the + /// transition, so without this the only way to test it is Adam's wrist. + /// + /// Launch with `-MeshMapperAutoDimAfter `. + @State private var debugForcedDim = false + #endif + + /// Content choice and display cadence are independent. A chosen readout can + /// run at full luminance with a precise timer, and reduced luminance settles + /// on that same approved surface because MapKit is not worth holding through + /// a wrist-down. + /// + /// It settles there *after* the hold, not at the dim — see `dimmedMapHold`. + private var showsMap: Bool { + settings.mainPageContent == .map + && (!isLuminanceReduced || isWithinDimmedMapHold) + } + + /// Whether the phone should keep sending map geography. + /// + /// **Deliberately not `showsMap && isSelected`.** The hold below keeps + /// drawing the pings the watch already has; it must not also keep the + /// phone's geo claim open. `WatchBridgeService` only schedules suppression + /// 15 s out, so a glance already costs about 21 s of geo — 5.9 s of map plus + /// that delay — and holding the claim for the hold's duration as well would + /// add the whole of `dimmedMapHold` on top, for a map the wearer was looking + /// at either way. Stated as a ratio this used to read "roughly double"; the + /// hold is now 12 s rather than 20, so it is nearer half again — the argument + /// is unchanged, only the arithmetic. + private var needsMapGeo: Bool { + settings.mainPageContent == .map && !isLuminanceReduced && isSelected + } + + /// How long the map keeps drawing after the display dims. + /// + /// **The dim is not the wrist coming down.** watchOS drops to reduced + /// luminance about 5.9 seconds into a glance — 33 lifts measured, 14 of them + /// inside a 0.49 s band, seven between 5.88 and 5.91 s — and the *clock face* + /// dims in about the same time, so this is the platform's full-brightness + /// window rather than anything this app can hold on to. The display stays + /// legible: "the screen does get a bit dimmer but the level is relatively + /// high still." + /// + /// `showsMap` treated that as nobody looking and tore the whole MapKit + /// subtree down mid-glance. The wearer's report: "the watch often falls back + /// to the always on state very rapidly despite still being in the lifted + /// position. This can make the map flicker on and disappear while a user is + /// looking at it." + /// + /// **Twelve seconds, down from twenty, and the cost model behind that has + /// been corrected.** The earlier note priced the hold as "MapKit stays + /// constructed and rendering for the duration". It does not: watchOS suspends + /// this app about a second into a wrist-down — 7.42 s lost of an 8.20 s drop, + /// 12.89 of 13.63, 23.01 of 23.99 — so almost none of that rendering happens. + /// What the hold actually buys is a bright basemap held on an OLED always-on + /// display, and that cost is paid whether or not the app is scheduled. + /// + /// The dim lands 5.9 s in, so twelve seconds keeps the map up until nearly + /// 18 s into a glance, which still covers a deliberate one with room to + /// spare. Twenty spent most of its length showing a map to nobody: a typical + /// glance is over long before 26 s. The scrim in `map` cuts what each + /// remaining second costs; this cuts how many of them there are. + /// + /// It leaves the *radio* alone — `needsMapGeo` above is untouched by the + /// hold. + /// + /// The floor is the flicker this exists to prevent. Do not take it below the + /// 5.9 s dim by much, or `showsMap` starts tearing the subtree down inside a + /// glance again, which is the defect the wearer reported: "the map flicker on + /// and disappear while a user is looking at it." + private static let dimmedMapHold: TimeInterval = 12 + + + /// When the display last dimmed, or nil at full luminance. + @State private var dimmedAt: Date? + + /// Exists to make a render happen at the end of the hold, not to decide when + /// the hold ends — see `isWithinDimmedMapHold` for why that distinction is + /// load-bearing. + @State private var dimmedMapHoldTask: Task? + + /// Whether the dim is recent enough that the wearer is plausibly still + /// reading the map. + /// + /// **A missing timestamp means hold — but only if this page has been seen at + /// full luminance.** `dimmedAt` is written by `onChange`, which runs *after* + /// the body evaluation that first sees reduced luminance. Defaulting to "not + /// holding" would therefore give that first frame `showsMap == false` and + /// only restore the map on the update the callback provokes — turning the + /// flicker this fixes into a teardown and a full rebuild, complete with a + /// fresh camera anchor, at the exact moment the wearer is looking at it. + /// + /// `hasBeenBright` is what keeps that default from swallowing the Always-On + /// design. A page that *appeared* already dimmed was never mid-glance, and + /// without this it opened straight onto MapKit — measured, not theorised: a + /// force-dimmed cold launch built the map subtree until this guard existed. + /// + /// **Past that, read the clock rather than a flag the expiry task cleared.** + /// watchOS suspends this app within about a second of the dim — measured by + /// the removed hitch detector, which lost 7.42 s of an 8.20 s wrist-down, + /// 12.89 of 13.63, 23.01 of 23.99 — so `dimmedMapHoldTask` cannot be relied + /// on to fire on time, and a boolean it owns would sit `true` for a whole + /// wrist-down. Evaluating the timestamp means every render that actually + /// happens gets the right answer, whenever it happens. + /// + /// That still needs a render to happen at all, which is what + /// `dimmedMapHoldTicker` is for. + /// Whether a dim arriving right now could hold anything. + /// + /// The same conditions `isWithinDimmedMapHold` applies, minus the ones that + /// only make sense once a hold exists. Kept separate so arming can be + /// skipped entirely rather than armed and then rejected on every render. + private var canHoldMapThroughDim: Bool { + settings.mainPageContent == .map && isSelected && hasBeenBright + } + + private var isWithinDimmedMapHold: Bool { + guard isSelected, isLuminanceReduced, hasBeenBright, !isDimmedMapHoldExpired + else { + return false + } + guard let dimmedAt else { return true } + return Date().timeIntervalSince(dimmedAt) < Self.dimmedMapHold + } + + /// Latched by the expiry task or the ticker, so a hold that has ended cannot + /// be revived by `dimmedAt` being cleared out from under it. + @State private var isDimmedMapHoldExpired = false + + /// Whether this page has been *watched* — selected and at full luminance — + /// at some point since its state was created. + /// + /// Only then can a dim be part of a glance rather than the state the page was + /// born into. It is deliberately not cleared by the view lifecycle: doing so + /// broke the feature on hardware, because watchOS re-hosts the page at the + /// dim and the reset landed mid-glance. Fresh `@State` already covers the + /// case it was meant to, since a page SwiftUI genuinely rebuilds starts + /// `false`. + /// + /// What it guards is narrower than an earlier comment here claimed. An + /// off-screen page is already excluded — `isWithinDimmedMapHold` checks + /// `isSelected` directly. This covers the two cases that check cannot see: a + /// page that appeared already dimmed, and one selected for the first time + /// while dimmed. Neither is a glance in progress. + @State private var hasBeenBright = false + + @State private var camera: MapCameraPosition = .automatic + + /// Where the puck is *drawn*, as opposed to where the phone says it is. + /// + /// **The puck cannot read `fix` directly if the camera is ever animated.** + /// `fix` is derived from `client.snapshot`, which the transport replaces in + /// its own transaction, so by the time this page reacts the annotation has + /// already moved. Easing the camera afterwards would leave the puck sliding + /// away from centre and snapping back — the wander that `recenterIfFollowing` + /// cuts to avoid. Holding the rendered position in `@State` lets both change + /// inside one `withAnimation`, so they travel on the same curve and the puck + /// stays visually still while the world moves beneath it. + /// + /// Scoped to the native map's lifetime like `hasAssertedRegion`: `mapContent` + /// seeds it on appear, so a rebuilt map opens on the truth rather than + /// animating in from wherever the last glance ended. + @State private var displayedFix: CLLocationCoordinate2D? + + /// Non-nil once we have driven the camera. Assignment is not proof that + /// MapKit rendered the request, but it is the first half of the span + /// handshake that keeps `.automatic` from becoming the remembered zoom. + /// + /// It doubles as the in-memory half of the centre seed: it is by definition + /// the last place we drove the camera to, so a rebuilt map can be restored + /// from it without waiting on a fix. + @State private var programmaticCenter: CLLocationCoordinate2D? + + /// Whether *this* native map has been given a region of ours. + /// + /// Scoped to the map's lifetime like `hasConfirmedRequestedSpan`, and for the + /// same reason: `MapPage` survives a wrist drop while the `Map` inside it does + /// not, so a flag scoped to the page would claim a freshly built map was + /// already anchored. Cleared in `mapContent`'s `onAppear`. + /// + /// This is what makes the anchor idempotent. Follow re-asserts on every geo + /// update and that is fine; the anchor must fire exactly once per map, or a + /// wearer with Follow off would have their Crown zoom overwritten by a + /// re-assert on every panel resize. + @State private var hasAssertedRegion = false + + /// Span counterpart to the centre handshake: MapKit can report its old + /// `.automatic` fit after we assign a region, so assignment alone is not + /// evidence that a rendered zoom came from us. + /// + /// Scoped to the *native map's* lifetime, not the page's. Dimming tears the + /// map subtree down while `MapPage` — and therefore this `@State` — survives, + /// so a confirmation earned before a wrist drop would otherwise still be + /// standing when a freshly built `Map` emits its first region. Nothing proves + /// that region is ours, and with the handshake already satisfied + /// `noteRenderedRegion` would bank it as a Crown zoom and overwrite the + /// wearer's saved span. `mapContent` clears it on appear so every native map + /// re-earns the confirmation. + /// + /// **Load-bearing, and measured so.** The simulator gave no support for this + /// at all — three teardown cycles, 0.0% drift every time — and it was written + /// as speculative hardening. Hardware disagreed immediately. Once the camera + /// falls into `.automatic` it reports a continental fit on *every* subsequent + /// rebuild: twenty consecutive callbacks at 34.364390 degrees, ~3,800 km, + /// against a requested 0.000500. Each one arrives at a freshly built map with + /// this flag cleared, so the 25% gate rejects it and the wearer's saved zoom + /// survives a completely broken camera. Without the reset, the first of those + /// twenty would have been banked as a Crown zoom and persisted. + /// + /// This protects the *setting*, not the view. The camera getting stuck is a + /// separate defect — see `recenterIfFollowing`. + @State private var hasConfirmedRequestedSpan = false + + /// Initial confirmation only needs to distinguish our request from the much + /// wider automatic annotation fit. MapKit may adjust a requested region for + /// display geometry, so a generous tolerance avoids quietly disabling Crown + /// persistence by waiting forever for an exact span. + private static let spanConfirmationTolerance = 0.25 + + private var snapshot: WatchSnapshot? { client.snapshot } + + private var fix: CLLocationCoordinate2D? { + guard let you = snapshot?.geo.you else { return nil } + return CLLocationCoordinate2D(latitude: you.lat, longitude: you.lon) + } + + private var isFollowing: Bool { + settings.follow + } + + @State private var showingNodes = false + @State private var armedToolbarControl: TrailingToolbarControl? + @State private var disarmToolbarTask: Task? + + private enum TrailingToolbarControl: Equatable { + case start + case stop + case ping + + var command: WatchCommand.Kind { + switch self { + case .start: return .startSession + case .stop: return .stopSession + case .ping: return .manualPing + } + } + } + + #if DEBUG + /// When the phase last changed, opening a short window in which the camera + /// probes below are allowed to speak. + /// + /// **The volume is the point.** Adam sees the map jump at the end of the wait + /// timer and again at the end of the listening timer, and three things happen + /// at exactly those moments: a new fix can arrive and recentre, the status + /// panel's height can change and recentre through `PanelHeightKey`, or the + /// subtree can be rebuilt. A probe on every camera callback would drown that + /// in follow updates — the mistake this file's wake logging made once + /// already, at roughly 400 file writes an hour against the thing being + /// measured. Logging only around a boundary keeps a walk's worth of evidence + /// down to a handful of lines per cycle. + @State private var phaseBoundaryAt: Date? + + /// The phase as of the last boundary this page logged. + @State private var boundaryPhase: String? + + private static let phaseBoundaryWindow: TimeInterval = 2.5 + + /// SwiftUI does not promise an order between two `onChange` handlers on the + /// same view, so a window that only opened when the phase probe happened to + /// run first would miss exactly the callbacks worth catching. A phase that + /// has not been recorded yet counts as inside it. + private var isNearPhaseBoundary: Bool { + if snapshot?.phase != boundaryPhase { return true } + guard let phaseBoundaryAt else { return false } + return Date().timeIntervalSince(phaseBoundaryAt) < Self.phaseBoundaryWindow + } + #endif + + /// The panel's measured height, which is what the camera inset needs and, + /// unlike its position, does not change while a page transition is animating. + @State private var panelHeight: CGFloat = 0 + + /// The panel height the **camera** frames against, which is not always the + /// height just measured. + /// + /// **The panel thrashes when a session starts or stops, and every value it + /// passes through used to be a camera reframe.** Measured on the wrist: + /// 54 -> 59 -> 40 -> 54 inside three seconds across + /// `skipped -> idle -> starting -> listening_discovery`, four `applyRegion` + /// assignments, none animated. Ordinary phase boundaries did *not* move the + /// panel — it held at 54.0 through four of them — so this is a session-lifecycle + /// event, not a per-cycle one. + /// + /// **The inset changes apparent zoom, not just the centre.** `panelCameraInset` + /// feeds `.safeAreaPadding(.bottom,)`, and `applyRegion` holds the span + /// constant, so a taller panel squeezes the same span into fewer points and + /// the map reads as zooming *in*. Adam on the first asymmetric attempt: *"when + /// I click stop it looks like it zooms in and out slightly"* — a grow adopted + /// at once, then a shrink adopted 450 ms later, each one a visible scale + /// change. Hence one settled adoption for both directions: the wearer should + /// see the map adjust once, not twice. + @State private var cameraPanelHeight: CGFloat = 0 + @State private var panelSettleTask: Task? + + /// The tallest the panel has been this launch, and what the camera actually + /// frames against. + /// + /// **Adam's simplification, and it is the right one: treat the panel as though + /// it were always at its maximum.** A settled, animated reframe was still one + /// visible zoom step per session transition, because a panel that populates to + /// two rows and later loses them is a real change in what MapKit is asked to + /// fit. Framing against the high-water mark means a *shrink* moves the camera + /// not at all — the common case, and the one Adam saw — while growth still + /// gets the settle and the ease below. + /// + /// **The cost, stated plainly:** while the panel is shorter than its maximum + /// the map is framed as if it were not, so the puck sits a few points above + /// the true centre of the visible band. Half the difference in heights, so + /// under ~10 pt at the sizes measured. A puck slightly off centre that never + /// moves reads far better than a centred one that jumps, which is the whole + /// trade being made here. + /// + /// Per launch rather than persisted: `@State` on the page survives the map + /// subtree's teardown on a wrist raise, so the mark holds across glances, and + /// a fresh launch re-derives it rather than inheriting a stale measurement + /// from a type ladder or content shape that has since changed. The 0.75-of- + /// display clamp in `panelCameraInset` still bounds it. + @State private var panelHighWaterMark: CGFloat = 0 + + /// Long enough to swallow a session transition's churn, short enough that the + /// map does not sit visibly mis-framed while it waits. + private static let panelSettleDelay: Duration = .milliseconds(450) + @State private var latchedTopSafeAreaInset: CGFloat = 0 + @State private var currentTopSafeAreaInset: CGFloat = 0 + @State private var bottomSafeAreaInset: CGFloat = 0 + + /// Only one subtree sets this, but every *other* subtree still contributes + /// the default. Taking `nextValue()` unconditionally would let a later + /// sibling's `.zero` overwrite the real measurement, so empties are ignored. + /// + /// This carries the panel's **height**, never its position. Position was a + /// runaway loop: an interactive page swipe translates the page, so a global + /// frame changes every frame, which changed the camera inset, which + /// re-framed the map, which invalidated layout, which re-measured. The watch + /// wedged with a blurred half-finished transition — 21,611 MapKit + /// reconfigurations and 4,300 body evaluations per second, main thread never + /// free. Height does not change when the page moves, so the cycle cannot + /// close. A programmatic selection change never exposes those intermediate + /// positions, which is why only a real swipe reproduced it. + private struct PanelHeightKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + let next = nextValue() + if next > 0 { value = next } + } + } + + /// How much of the display the status panel is covering, handed to MapKit so + /// it frames the camera in the band that remains visible. + /// + /// Derived from the panel's height plus the gap it leaves beneath itself, + /// which reconstructs exactly what a bottom-anchored panel occupies without + /// asking where it currently is. Measured rather than assumed because the + /// height changes with content — one column or two, with or without heard + /// rows — and a stale constant would drift the fix off centre precisely when + /// the panel grew. + /// Reads `cameraPanelHeight`, never the raw measurement, so a boundary's + /// transient heights never reach MapKit. + private var panelCameraInset: CGFloat { + guard cameraPanelHeight > 0 else { return 0 } + let gapBeneath = curvedPanelHorizontalInset == nil + ? bottomSafeAreaInset + : panelBottomGap + // Clamped: an inset approaching the display height would leave MapKit no + // band to frame, and nothing about a status panel justifies that. + let limit = WKInterfaceDevice.current().screenBounds.height * 0.75 + return min(max(0, cameraPanelHeight + gapBeneath), limit) + } + + /// Lets the measured height stop moving before the camera follows it, in both + /// directions, and animates the single reframe that results. + /// + /// **One adoption, not one per measurement.** In the measured + /// 54 -> 59 -> 40 -> 54 swing the camera is assigned once, at 54 — which is + /// where it started, so the whole session transition costs *no* reframe at + /// all. The intermediate 59 and the 19 pt collapse to 40 never reach MapKit. + /// An earlier attempt adopted growth immediately to guarantee the panel could + /// never cover the puck; that produced two visible scale changes 450 ms apart + /// and the protection was unnecessary at real panel sizes, where the puck sits + /// roughly 85 pt above a panel top near 170. + /// + /// **Animated, because a settled change is a rare and deliberate one.** It + /// happens on a session start or stop, not every cycle, so there is nothing + /// for the 0.25 s ease to compete with — the objection to animating was four + /// overlapping reframes, and there is now at most one. + /// + /// **Deliberately fail-safe rather than timer-dependent.** watchOS suspends + /// this app, so the sleep below fires whenever it is next resumed rather than + /// on schedule — the trap the dim hold was bitten by twice. A late fire only + /// reframes late. Against a fire that never comes, the map subtree's + /// `onAppear` reconciles the two heights outright, so no wrist raise can find + /// the camera framing against a height the panel abandoned. + private func adoptPanelHeightForCamera() { + panelHighWaterMark = max(panelHighWaterMark, panelHeight) + panelSettleTask?.cancel() + panelSettleTask = nil + // A shrink cannot change the mark, so it lands here and stops — no task, no + // reframe, nothing for the wearer to see. That is the point. + guard cameraPanelHeight != panelHighWaterMark else { return } + + panelSettleTask = Task { @MainActor in + try? await Task.sleep(for: Self.panelSettleDelay) + guard !Task.isCancelled, panelHighWaterMark != cameraPanelHeight else { return } + #if DEBUG + WakeLog.note( + String( + format: "panel-settled %.1f -> %.1f (measured %.1f)", + cameraPanelHeight, panelHighWaterMark, panelHeight + ) + ) + #endif + cameraPanelHeight = panelHighWaterMark + panelSettleTask = nil + recenterIfFollowing(animated: true) + } + } + + /// Drops any pending settle and takes the measured height as it stands. + /// + /// The safety net for the suspension case above, and the right behaviour on an + /// appearance regardless: a subtree that is being built has no in-flight + /// framing for a wearer to be watching, so there is nothing to animate and + /// nothing to smooth over. + private func adoptPanelHeightNow() { + panelHighWaterMark = max(panelHighWaterMark, panelHeight) + panelSettleTask?.cancel() + panelSettleTask = nil + guard cameraPanelHeight != panelHighWaterMark else { return } + cameraPanelHeight = panelHighWaterMark + recenterIfFollowing() + } + + /// The placement scales with the estimated corner radius, putting every + /// watch at the same relative position on its curve. Ten points at R=19 is a + /// modest lift from the hardware-approved eight; unlike the earlier + /// deliberately narrow treatment, clearance is now evaluated at that real + /// position so the lift buys the glanceable width the wearer requested. + private static let panelBottomGapRatio: CGFloat = 10.0 / 19.0 + private static let readoutFailureBannerBottomGap: CGFloat = 3 + + private var panelBottomGap: CGFloat { + bottomSafeAreaInset * Self.panelBottomGapRatio + } + + /// Panel geometry is about the physical display, not this view's proposal. + /// Reading the device avoids a feedback loop where panel padding changes the + /// container width that is then used to recompute that same padding. + private var screenWidth: CGFloat { WKInterfaceDevice.current().screenBounds.width } + + /// Horizontal clearance that lets the panel descend into the bottom safe + /// area without putting its corners outside the curved display. + /// + /// watchOS exposes no screen corner radius, but its bottom safe-area inset is + /// the clearance a full-width element needs at zero horizontal inset, making + /// it a useful estimate of that radius. The circle/chord intersection gives + /// the inset at the panel's actual placement gap. That radius estimate is a + /// lower bound on the glass curvature, so four extra points are cheap + /// insurance against another hardware clip. The rectangle test is + /// conservative in the other direction: the panel's own 12 pt radius pulls + /// its visible corners inward from the square corners protected here. + private var curvedPanelHorizontalInset: CGFloat? { + let radius = bottomSafeAreaInset + let gap = panelBottomGap + guard radius > 0, gap < radius else { return nil } + let inset = radius - sqrt(max(0, 2 * radius * gap - gap * gap)) + 4 + guard inset.isFinite else { return nil } + return inset + } + + /// A missing safe-area measurement is not permission to draw to the edge: + /// retaining the old four-point inset and safe-area placement is the only + /// fallback that is known not to clip on hardware. + private var panelHorizontalInset: CGFloat { curvedPanelHorizontalInset ?? 4 } + + /// Width left after the curvature clearance and the panel's own 8 pt inset + /// on each side. Row sizing uses this same budget as the rendered content, + /// so its two-column decision does not depend on a nominal watch size. + private var panelContentWidth: CGFloat { + max(0, screenWidth - 2 * panelHorizontalInset - 16) + } + + /// A centred toast with one radius of margin on each side is entirely + /// inboard of the bottom curve, so its vertical position no longer needs the + /// panel's chord calculation. Absence remains distinct from zero: without a + /// measured radius the banner must stay in the safe area. + private var readoutFailureBannerMaxWidth: CGFloat? { + guard bottomSafeAreaInset > 0 else { return nil } + let width = screenWidth - 2 * bottomSafeAreaInset + guard width.isFinite, width > 0 else { return nil } + return width + } + + var body: some View { + pageContent + // **Both items stay in the toolbar at all times.** They used to be wrapped + // in `if !isLuminanceReduced`, and removing them at the dim changes the + // toolbar's structure, which re-hosts this page and rebuilds the entire + // MapKit subtree underneath it. Measured on a Series 9: a + // `map-subtree-appeared` 0.13 s after every single `hold-armed`, with the + // old subtree discarded 0.09 s later — a full teardown, fresh camera + // anchor and basemap repaint, right under the wearer's eye. Adam saw it + // as the map jumping as the display faded. With the items always present + // the dim rebuilds nothing at all. + // + // Hidden by value rather than by presence, and hidden *properly*: + // `.opacity(0)` alone leaves a live button, and `.allowsHitTesting` + // covers ordinary touch but neither disables the action nor takes it out + // of the accessibility tree. Start transmits on a single tap with no + // confirmation, so a VoiceOver focus carried across the dim could fire + // it on an invisible control. `hiddenWhileDimmed` disables and + // accessibility-hides as well; all three are value changes, so the + // toolbar's structure stays fixed. + .toolbar { + ToolbarItem(placement: .topBarLeading) { + mainPageToggle.hiddenWhileDimmed(isLuminanceReduced) + } + ToolbarItem(placement: .topBarTrailing) { + trailingToolbarButton.hiddenWhileDimmed(isLuminanceReduced) + } + } + .background( + GeometryReader { geo in + Color.clear + .onAppear { + noteTopSafeAreaInset(geo.safeAreaInsets.top) + latchBottomSafeAreaInset(geo.safeAreaInsets.bottom) + } + .onChange(of: geo.safeAreaInsets.top) { _, inset in + noteTopSafeAreaInset(inset) + } + .onChange(of: geo.safeAreaInsets.bottom) { _, inset in + latchBottomSafeAreaInset(inset) + } + .onChange(of: isLuminanceReduced) { _, reduced in + // If the view first appeared while dimmed, returning to full + // luminance on the readout is the first valid opportunity to + // establish the reference even when the inset did not change. + if !reduced && !showsMap { + noteTopSafeAreaInset(geo.safeAreaInsets.top) + } + } + .onChange(of: showsMap) { _, mapIsShowing in + // Switching from the map supplies a readout reference even if + // both surfaces initially report the same transient inset. + if !mapIsShowing && !isLuminanceReduced { + noteTopSafeAreaInset(geo.safeAreaInsets.top) + } + } + } + // Plain is intentional: `.ignoresSafeArea()` makes this reader report + // the insets of its own expanded region, which are zero. The bottom's + // first nonzero value is latched before its dependent panel geometry can + // feed back into layout; the top follows the separate visual-only rule + // in `noteTopSafeAreaInset`. + ) + .sheet(isPresented: $showingNodes) { + NavigationStack { + NodeListView() + .navigationTitle("Heard") + .navigationBarTitleDisplayMode(.inline) + } + } + .onAppear { + #if DEBUG + // Lets the sheet layout be captured and iterated on headlessly; the + // simulator has no way to tap the bar. + if UserDefaults.standard.bool(forKey: "MeshMapperShowNodeSheet") { + showingNodes = true + } + // A refusal normally needs a real command round-trip, which makes this + // transient impossible to capture headlessly. It still expires through + // the production six-second path, so screenshots must be prompt. + if let forced = UserDefaults.standard.string(forKey: "MeshMapperForceRefusal") { + client.debugForceRefusal(forced) + } + // Time the readout->map switch, which rebuilds the same MapKit tree a + // wrist raise does — the one thing about a raise that is reproducible + // without a wrist, since Always-On cannot be entered here. Pair it with + // `-layout.mainPageContent readout` or the flip is a no-op against a + // map that is already showing, which reads as a suspiciously fast + // result rather than as no measurement at all. + let autoDim = UserDefaults.standard.integer(forKey: "MeshMapperAutoDimAfter") + if autoDim > 0 { + Task { @MainActor in + try? await Task.sleep(for: .seconds(autoDim)) + WakeLog.note("debug-forcing-dim") + debugForcedDim = true + } + } + if UserDefaults.standard.bool(forKey: "MeshMapperTimeSwitchToMap") { + Task { @MainActor in + try? await Task.sleep(for: .seconds(5)) + NSLog("[switch] requesting map at %f", Date().timeIntervalSince1970) + settings.mainPageContent = .map + } + } + #endif + client.setMapGeoNeeded(needsMapGeo) + // A page that opens selected and bright is a glance in progress; one + // that opens dimmed or behind another page is not, and must not hold a + // map it never showed. + armGlanceIfWatched() + } + .onChange(of: needsMapGeo) { _, needed in + client.setMapGeoNeeded(needed) + } + .onChange(of: isSelected) { _, selected in + // A sheet belongs to the page that raised it. Leaving one presented + // while the pager moves on strands a modal over a different page, + // where it blurs that page and swallows every swipe and Crown turn — + // the app looks crashed while it is merely holding a stuck sheet. + if !selected { showingNodes = false } + noteSelection(selected) + } + .onChange(of: trailingToolbarControl) { _, _ in + // Stable facts own the slot, but a session transition still changes + // its meaning. Never carry an armed confirmation into a new action. + disarmToolbarControl() + } + .onChange(of: trailingToolbarControlIsEnabled) { _, enabled in + if !enabled { disarmToolbarControl() } + } + .onChange(of: isLuminanceReduced) { _, reduced in + noteLuminance(reduced: reduced) + if reduced { disarmToolbarControl() } + #if DEBUG + // The reference timestamp for a wrist-raise measurement. Nothing else + // marks the moment the wrist came up, so without this the `map` and + // `span` lines have no zero to be measured against. + WakeLog.note(reduced ? "wrist-down" : "wrist-up") + #endif + } + // Deliberately does NOT touch the hold. Apple's guidance is that Always On + // keeps views in the hierarchy, but hardware also collapses the + // navigation bar at the dim where the simulator does not — and the + // re-host that causes fired this handler mid-glance, which destroyed the + // hold about a second in and put the wearer straight back on the readout. + // `@State` already gives the right answer for free: if SwiftUI really + // destroys this page, `hasBeenBright` goes with it and a page rebuilt + // while dimmed starts unarmed. If it survives, so should the glance. + .onDisappear { disarmToolbarControl() } + // A main-actor "hitch" detector lived here — a 250 ms tick that logged + // whenever it lost more than a second — meant to decide whether the 6.1 s + // wake stall was the app being descheduled or SwiftUI deferring the + // rebuild. It was removed because it cannot tell those apart from the one + // thing that always happens: watchOS suspends this app while the wrist is + // down, so the tick simply stops. Measured, it fired on every single wake + // at almost exactly the wrist-down duration — 7.42 s against 8.20 s, + // 12.89 against 13.63, 23.01 against 23.99. It reports suspension and + // calls it a stall. + // + // Its silence was equally worthless, and had already been cited as + // evidence that the stall "did not recur". Anything replacing it must + // establish the app was actually scheduled — a suspension-aware signal + // such as `scenePhase`, not a timer that cannot observe its own absence. + } + + private var pageContent: some View { + ZStack { + if showsMap { + // Keep the map inside this branch: constructing even map + // infrastructure behind the readout would defeat its battery purpose. + // + // There is deliberately no `MapReader` here. One wrapped this content + // while `centerPlacing` and `correctPlacement` translated the camera + // through `proxy.convert`; camera placement is now `.safeAreaPadding`, + // which insets MapKit's own framing, so no coordinate conversion + // happens anywhere in this file. The proxy was threaded through six + // functions unread. Do not reintroduce a reader to place the camera — + // see the handoff: the map's SwiftUI frame is not the map, and any + // offset computed from view geometry under-shoots. + mapContent + } else { + readoutContent + } + } + // Attached as a background rather than as a sibling in the ZStack. As a + // sibling it enters the stack exactly when luminance drops, which + // re-identifies the branch beside it and rebuilt the whole MapKit subtree + // at the dim — measured on hardware as `map-subtree-appeared` 0.13 s after + // every `hold-armed`, and visible on the wrist as the map jumping right + // as it dims. A background cannot change the identity of the content it + // decorates. + .background(dimmedMapHoldTicker) + } + + /// Asks watchOS to render this page again when the hold ends. + /// + /// **Without this the hold could not end during a real wrist-down at all.** + /// The clock in `isWithinDimmedMapHold` only decides the answer for renders + /// that happen, and a suspended app renders nothing: the last frame drawn + /// stays on the Always-On display until something asks for another. Hold the + /// map and then suspend, and the map — not the approved readout — would be + /// the Always-On surface for the whole time the wrist was down. + /// + /// A timeline entry is the supported way to ask for that render — the same + /// mechanism the readout's coarse countdown uses while dimmed, though not + /// literally the same wake, since that countdown is not in the tree while the + /// map is held. watchOS may still coalesce them into one cadence. The + /// schedule repeats rather than naming the single boundary: a two-entry + /// `.explicit` schedule can be exhausted by an early re-query and then never + /// ask for another render at all, which would strand the map on the display. + /// Repeating entries cannot run out, and the ticker leaves the tree entirely + /// once the hold is expired, so nothing keeps asking. + /// + /// Always-On throttles these updates to roughly one a minute, so the readout + /// may return well after the twelve seconds `dimmedMapHold` asks for — and + /// the shorter the hold, the larger that overshoot is in proportion. Nobody + /// is looking by then; the point is that it returns without needing the + /// wrist. The scrim on `map` is what bounds the cost of the overshoot, since + /// the basemap is dimmed for every second of it. + @ViewBuilder + private var dimmedMapHoldTicker: some View { + if isLuminanceReduced, !isDimmedMapHoldExpired, let dimmedAt { + TimelineView(.periodic(from: dimmedAt, by: Self.dimmedMapHold)) { context in + Color.clear + .frame(width: 0, height: 0) + .onChange(of: context.date) { _, _ in + // **A schedule entry arriving is not proof its deadline arrived.** + // Entering Always On changes the timeline's cadence, which + // re-queries the schedule, and SwiftUI can hand over the next entry + // there and then. An earlier version treated the date changing as + // the boundary and called this directly: on a Series 9 it expired + // the hold 0.82, 0.85, 0.94 and 1.05 s after the dim, four glances + // out of four, while the simulator — which never enters Always On, + // so never changes cadence — passed every time. + // + // The clock is the authority, exactly as it is in + // `isWithinDimmedMapHold`. This callback only decides whether the + // render that just happened is the one that ends the hold. + guard Date().timeIntervalSince(dimmedAt) >= Self.dimmedMapHold else { + return + } + expireDimmedMapHold() + } + } + } + } + + private var commandFailure: String? { + guard !isLuminanceReduced, let refusal = client.lastRefusal else { + return nil + } + switch client.lastRefusalCommand { + case nil, .startSession, .stopSession, .manualPing: + return refusal + case .requestSnapshot: + return nil + } + } + + @ViewBuilder + private var commandFailureBanner: some View { + failureBanner(fillsAvailableWidth: true) + } + + @ViewBuilder + private var compactCommandFailureBanner: some View { + failureBanner(fillsAvailableWidth: false) + } + + @ViewBuilder + private func failureBanner(fillsAvailableWidth: Bool) -> some View { + if let commandFailure { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Image(systemName: "exclamationmark.circle.fill") + Text(commandFailure) + .lineLimit(2) + } + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(WatchPalette.armed) + .multilineTextAlignment(.leading) + .frame( + maxWidth: fillsAvailableWidth ? .infinity : nil, + alignment: .leading + ) + .padding(.horizontal, 7) + .padding(.vertical, 4) + .background( + .ultraThinMaterial, + in: .rect(cornerRadius: WatchPalette.cornerRadius, style: .continuous) + ) + .overlay( + RoundedRectangle( + cornerRadius: WatchPalette.cornerRadius, + style: .continuous + ) + .stroke(.white.opacity(0.12), lineWidth: 0.5) + ) + // Placement belongs to each surface's overlay. The banner itself owns no + // layout lifetime or geometry; the client still expires it after six + // seconds, and reduced luminance suppresses it in `commandFailure`. + .allowsHitTesting(false) + } + } + + private var mainPageToggle: some View { + Button { + settings.mainPageContent = settings.mainPageContent == .map + ? .readout + : .map + } label: { + // The glyph names the destination, following the convention for a + // two-state corner control. The framed list survives toolbar scaling on + // 40 mm better than bare bullets and still reads as the full readout. + Image(systemName: settings.mainPageContent == .map + ? "list.bullet.rectangle" + : "map.fill") + } + .accessibilityLabel(settings.mainPageContent == .map + ? "Show readout" + : "Show map") + } + + /// Slot identity follows stable session and regional facts, never the live + /// ping gate. A cooldown may disable Ping, but cannot replace it with Stop + /// while a finger is already moving toward the corner. + private var trailingToolbarControl: TrailingToolbarControl { + guard client.snapshot?.controls.isSessionActive == true else { return .start } + if settings.showPingWhenAvailable, + client.snapshot?.controls.manualPingApplicable == true + { + return .ping + } + return .stop + } + + private var trailingToolbarControlIsEnabled: Bool { + guard client.pendingCommand != trailingToolbarControl.command else { return false } + switch trailingToolbarControl { + case .start, .stop: + return client.snapshot?.controls.canStartStop == true + case .ping: + return client.snapshot?.controls.canManualPing == true + } + } + + private var effectiveStartMode: WatchSettings.DefaultStartMode { + settings.effectiveStartMode( + availableStartModes: client.snapshot?.availableStartModes + ) + } + + @ViewBuilder + private var trailingToolbarButton: some View { + let control = trailingToolbarControl + let pending = client.pendingCommand == control.command + let armed = armedToolbarControl == control + + let button = Button { + handleToolbarControl(control) + } label: { + ZStack { + if pending { + ProgressView() + .controlSize(.mini) + .tint(toolbarActionColor(for: control)) + } else { + Image(systemName: toolbarIcon(for: control, armed: armed)) + .foregroundStyle(toolbarGlyphColor(for: control, armed: armed)) + } + } + .frame(width: 18, height: 18) + } + .disabled(!trailingToolbarControlIsEnabled) + .accessibilityLabel(toolbarAccessibilityLabel(for: control, armed: armed)) + + if armed { + // Resting actions use the same quiet glass container as the display + // toggle. The three-second confirmation window is deliberately the only + // filled state: its amber capsule signals that the next tap has a + // consequence, rather than merely decorating a persistent control. + button.tint(WatchPalette.armed) + } else { + button + } + } + + private func handleToolbarControl(_ control: TrailingToolbarControl) { + switch control { + case .start: + disarmToolbarControl() + client.send(.startSession, mode: effectiveStartMode.rawValue) + case .stop, .ping: + guard armedToolbarControl == control else { + armToolbarControl(control) + return + } + disarmToolbarControl() + client.send(control.command) + } + } + + private func toolbarIcon( + for control: TrailingToolbarControl, + armed: Bool + ) -> String { + // With no usable connection, a dim play symbol falsely suggests that the + // empty-looking glass control is a Start affordance. Name the unavailable + // prerequisite instead; enablement remains entirely phone-owned. + if snapshot?.isConnected != true { + return "antenna.radiowaves.left.and.right.slash" + } + if armed { return "checkmark" } + switch control { + case .start: return "play.fill" + case .stop: return "stop.fill" + case .ping: return "dot.radiowaves.left.and.right" + } + } + + private func toolbarActionColor( + for control: TrailingToolbarControl + ) -> Color { + switch control { + case .start: return WatchPalette.start + case .stop: return WatchPalette.stop + case .ping: return WatchPalette.ping + } + } + + private func toolbarGlyphColor( + for control: TrailingToolbarControl, + armed: Bool + ) -> Color { + // The darker disabled slate vanished against Apple's darkest basemap. + // System disabled dimming still distinguishes this brighter slate from an + // enabled action without making the glass circle look empty. + guard trailingToolbarControlIsEnabled else { return WatchPalette.tertiary } + if armed { return .white } + return toolbarActionColor(for: control) + } + + private func toolbarAccessibilityLabel( + for control: TrailingToolbarControl, + armed: Bool + ) -> String { + guard let snapshot else { return "Waiting for iPhone" } + guard snapshot.isConnected else { return "Device disconnected" } + switch control { + case .start: return "Start \(effectiveStartMode.label) session" + case .stop: return armed ? "Confirm stop session" : "Stop session" + case .ping: return armed ? "Confirm manual ping" : "Manual ping" + } + } + + private func armToolbarControl(_ control: TrailingToolbarControl) { + disarmToolbarTask?.cancel() + armedToolbarControl = control + disarmToolbarTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(3)) + guard !Task.isCancelled, armedToolbarControl == control else { return } + armedToolbarControl = nil + disarmToolbarTask = nil + } + } + + private func disarmToolbarControl() { + disarmToolbarTask?.cancel() + disarmToolbarTask = nil + armedToolbarControl = nil + } + + private func noteLuminance(reduced: Bool) { + guard reduced else { + armGlanceIfWatched() + resetDimmedMapHold() + return + } + // Nothing to hold means nothing to arm. `isWithinDimmedMapHold` rejects + // all of these anyway, but arming would still start a task and put a + // timeline in the tree, asking watchOS for an Always-On render that can + // only ever decide there was no hold. + guard canHoldMapThroughDim else { return } + dimmedMapHoldTask?.cancel() + dimmedAt = Date() + isDimmedMapHoldExpired = false + #if DEBUG + WakeLog.note("hold-armed bright \(hasBeenBright) selected \(isSelected)") + #endif + // Fires only while the app happens to still be scheduled, which is roughly + // the first second after the dim. `dimmedMapHoldTicker` is what covers the + // rest; this is here so a glance that keeps the app alive ends the hold at + // exactly the right moment rather than at the next system update. + dimmedMapHoldTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(Self.dimmedMapHold)) + guard !Task.isCancelled else { return } + expireDimmedMapHold() + } + } + + private func expireDimmedMapHold() { + dimmedMapHoldTask?.cancel() + dimmedMapHoldTask = nil + guard !isDimmedMapHoldExpired else { return } + isDimmedMapHoldExpired = true + #if DEBUG + WakeLog.note("hold-expired") + #endif + } + + private func resetDimmedMapHold(_ reason: String = "luminance") { + #if DEBUG + if dimmedAt != nil || isDimmedMapHoldExpired { + WakeLog.note("hold-reset \(reason)") + } + #endif + dimmedMapHoldTask?.cancel() + dimmedMapHoldTask = nil + dimmedAt = nil + isDimmedMapHoldExpired = false + } + + /// Ends both the hold and the glance that earned it. Only deselection does + /// this: it is an explicit statement that the wearer is looking elsewhere, + /// unlike a view-lifecycle callback, which on watchOS can fire for reasons + /// that have nothing to do with whether anyone is looking. + private func endDimmedMapHoldLifetime(_ reason: String) { + resetDimmedMapHold(reason) + #if DEBUG + if hasBeenBright { WakeLog.note("hold-disarmed \(reason)") } + #endif + hasBeenBright = false + } + + private func armGlanceIfWatched() { + if isSelected, !isLuminanceReduced { hasBeenBright = true } + } + + private func noteSelection(_ selected: Bool) { + // A hold belongs to a glance at *this* page. Swiping away ends the glance, + // whatever the display is doing. + guard selected else { + endDimmedMapHoldLifetime("deselected") + return + } + armGlanceIfWatched() + } + + private var mapContent: some View { + ZStack { + map + mapOverlay + } + .onPreferenceChange(PanelHeightKey.self) { height in + guard abs(height - panelHeight) > 0.5 else { return } + #if DEBUG + // Panel height is a camera input: `panelCameraInset` feeds + // `.safeAreaPadding(.bottom,)`, so the map reframes when the panel grows + // or shrinks. A ping that hears nothing replaces up to four heard rows + // with one "Nothing heard" line, which is exactly the cycle Adam + // described the jump on. + if isNearPhaseBoundary { + WakeLog.note( + String( + format: "boundary panel %.1f -> %.1f camera %.1f inset %.1f", + panelHeight, height, cameraPanelHeight, panelCameraInset + ) + ) + } + #endif + panelHeight = height + // Not straight to the camera: see `adoptPanelHeightForCamera`. A + // `boundary panel` line with no `boundary camera` line after it is this + // working — the measurement moved and the map did not. + adoptPanelHeightForCamera() + } + .onChange(of: snapshot?.geo.you.map { "\($0.lat),\($0.lon)" }) { previous, current in + #if DEBUG + if isNearPhaseBoundary { + WakeLog.note( + "boundary fix \(previous ?? "nil") -> \(current ?? "nil") " + + String(format: "moved %.1f m", fixDistanceMoved(previous, current)) + ) + } + #endif + trackFix() + // A first fix is the moment a map that had nothing to anchor to becomes + // placeable. Idempotent, so this is a no-op on every later update. + anchorCameraIfNeeded() + } + .onAppear { + // A new native map has to re-earn the span handshake, and holds no region + // of ours until one is asserted below. + hasConfirmedRequestedSpan = false + hasAssertedRegion = false + #if DEBUG + // Marks the MapKit subtree being rebuilt. This is the SwiftUI half of a + // wrist raise only — the basemap paints later, with no callback of any + // kind, so the gap from here to visible pixels needs a camera or an eye. + WakeLog.note("map-subtree-appeared") + // Read as a pair with `camera-after`. The span log cannot separate "we + // never asserted a region" from "we asserted one and MapKit rendered its + // own anyway", and those want different fixes: + // + // before automatic=true, after automatic=false, span still continental + // -> we asserted and were ignored; the assignment is not the lever + // before automatic=true, after automatic=true + // -> `anchorCenter` was nil, so nothing was available to anchor to + // before automatic=false, after automatic=false, span continental + // -> the camera holds a region but MapKit is not honouring it + WakeLog.note("camera-before \(cameraStateDescription)") + #endif + // Before recentring, not after: a pending settle from a previous + // appearance would otherwise have this frame framed against a height the + // panel has already left, and `adoptPanelHeightNow` recentres itself when + // it actually changes anything. + adoptPanelHeightNow() + // Before the recentre, and never animated: a fresh map must open on the + // truth. Animating in from the previous glance's position would slide the + // puck across the first frame of every wrist raise. + displayedFix = fix + recenterIfFollowing() + anchorCameraIfNeeded() + #if DEBUG + WakeLog.note("camera-after \(cameraStateDescription)") + // Close the boundary window this appearance opened. `onChange` does not + // fire for an initial value, so without seeding it here the phase would + // never match and every panel measurement and camera assignment for the + // rest of the session would log as though it sat on a boundary. + boundaryPhase = snapshot?.phase + #endif + } + #if DEBUG + // Separates "built twice" from "built, torn down, rebuilt". Every device + // wake logs two appearances 50-70 ms apart, and the shape of the pair + // decides where to look: an interleaved disappear means a genuine teardown, + // two bare appearances mean two live instances. + // + // **The pair is settled, and it is not ours.** It is construct → construct + // → discard the first, netting one live subtree. A probe placed as a bare + // sibling above `pageContent`'s `if showsMap` doubled identically, and so + // did one inside `readoutContent` on a launch with no MapKit anywhere — + // so this is the whole page being built twice, not anything about the map. + // Probes on the `NavigationStack` and the `TabView` each fired once, and + // it survived both removing the hoisted stack entirely and making the + // conditional Heard page unconditional. What remains is the vertical pager + // building its selected page twice at launch. Nothing here can prevent it; + // the cost is one discarded construction against a 0.104 s rebuild. + .onDisappear { WakeLog.note("map-subtree-disappeared") } + // Opens the window the other boundary probes report inside of. Placement + // in the chain does not decide whether this runs before them — + // `isNearPhaseBoundary` treats an unrecorded transition as inside the + // window, so a boundary's own first callbacks are covered either way. + .onChange(of: snapshot?.phase) { previous, current in + phaseBoundaryAt = Date() + boundaryPhase = current + WakeLog.note( + "boundary \(previous ?? "nil") -> \(current ?? "nil") " + + String( + format: "panel %.1f camera %.1f inset %.1f ", + panelHeight, cameraPanelHeight, panelCameraInset + ) + + cameraStateDescription + ) + } + #endif + } + + #if DEBUG + /// Distance between two `"lat,lon"` observation keys, for the boundary probe + /// only. The keys are this file's own formatting, so parsing them back is + /// cheaper than adding a second observed value beside them. + private func fixDistanceMoved(_ previous: String?, _ current: String?) -> Double { + guard let from = Self.probeCoordinate(previous), + let to = Self.probeCoordinate(current) + else { return 0 } + return CLLocation(latitude: from.latitude, longitude: from.longitude) + .distance( + from: CLLocation(latitude: to.latitude, longitude: to.longitude) + ) + } + + private static func probeCoordinate(_ key: String?) -> CLLocationCoordinate2D? { + let parts = key?.split(separator: ",") ?? [] + guard parts.count == 2, + let lat = Double(parts[0]), + let lon = Double(parts[1]) + else { return nil } + return CLLocationCoordinate2D(latitude: lat, longitude: lon) + } + #endif + + private var readoutContent: some View { + ZStack { + // Always-On spends most of a long session with the wrist down, while a + // wearer may also choose this as the full-luminance main page. In both + // cases a black backing keeps the approved flat layout independent of + // whatever container presents it. + Color.black.ignoresSafeArea() + readoutStatus + // Hardware may collapse the navigation bar in Always-On even though + // the simulator's force-dimmed path cannot. Restore only an inset that + // disappeared relative to the full-luminance reference: equal insets + // produce zero, while a missing measurement is deliberately a no-op. + // Layout padding also consumes the height the collapsed bar returned, + // keeping the flexible middle spacer identical to the reference. The + // reader above receives its safe-area inset from the navigation host; + // padding this descendant cannot alter that system-supplied value. + .padding(.top, readoutTopOffset) + // Anchor to this safe-area-respecting child, not `readoutContent`. + // Its black sibling ignores the safe area and expands the enclosing + // ZStack past the display bottom, which would place a bottom-aligned + // banner off-screen — the same zero-inset trap this file has hit when + // geometry was read from an expanded region. + .overlay { + if let maxWidth = readoutFailureBannerMaxWidth { + VStack(spacing: 0) { + Spacer(minLength: 0) + compactCommandFailureBanner + // The transparent frame supplies a wrapping proposal and + // centres the toast; its material background remains on the + // intrinsic content rather than expanding permanent-looking + // chrome to the cap. + .frame(maxWidth: maxWidth) + } + // Three points keep the capsule's antialiasing visibly off the + // physical edge. Curvature needs no further allowance once both + // horizontal margins are at least the measured radius. + .padding(.bottom, Self.readoutFailureBannerBottomGap) + .ignoresSafeArea(edges: .bottom) + } else { + VStack(spacing: 0) { + Spacer(minLength: 0) + commandFailureBanner + } + // No measured radius means no permission to enter the bottom safe + // area; preserve the last known-safe placement exactly. + .padding(.horizontal, panelHorizontalInset) + .padding(.bottom, 4) + } + } + } + } + + private var readoutTopOffset: CGFloat { + guard latchedTopSafeAreaInset > 0 else { return 0 } + return max(0, latchedTopSafeAreaInset - currentTopSafeAreaInset) + } + + /// Map chrome remains an overlay so its measured frame can place the fix in + /// the visible band above it. The readout has its own hierarchy and therefore + /// cannot accidentally inherit this bottom-pinned card again. + private var mapOverlay: some View { + VStack(spacing: 0) { + HStack { + Spacer(minLength: 0) + recenterButton + } + Spacer(minLength: 0) + statusPanel + .overlay(alignment: .top) { + commandFailureBanner + .padding(.horizontal, 4) + // Aligning a guide below the banner with the panel's top puts the + // transient immediately above the measured card. Unlike adding a + // VStack row, an overlay contributes no size, so neither the + // panel's signed-off placement nor its measured height can move. + .alignmentGuide(.top) { dimensions in + dimensions[.bottom] + 3 + } + } + .background( + GeometryReader { geo in + // Size, not frame: a global position moves with the page during + // an interactive swipe and closes a layout feedback loop. + Color.clear.preference(key: PanelHeightKey.self, value: geo.size.height) + } + ) + } + // Earlier full-width versions clipped on curved hardware even though the + // simulator looked sound. Enter the bottom safe area only after the circle + // model has bought the matching horizontal clearance. + .padding(.horizontal, panelHorizontalInset) + .padding(.top, 2) + .padding(.bottom, curvedPanelHorizontalInset == nil ? 0 : panelBottomGap) + .ignoresSafeArea(edges: curvedPanelHorizontalInset == nil ? [] : .bottom) + } + + /// A full-screen glance surface, not a map card without a map. + /// + /// Its content remains inside the system safe area and also keeps the + /// hardware-tested horizontal clearance. That is intentionally redundant at + /// the bottom corners: two earlier layouts passed in the simulator and + /// clipped on glass, while spare black pixels cost no compositing work. + private var readoutStatus: some View { + VStack(alignment: .leading, spacing: 0) { + if let snapshot { + ReadoutPhase(snapshot: snapshot, isLuminanceReduced: isLuminanceReduced) + } else { + Text("Waiting for iPhone") + .font(.system(size: 17, weight: .bold)) + .foregroundStyle(.white.opacity(0.55)) + .lineLimit(1) + } + + Spacer(minLength: 6) + + VStack(alignment: .leading, spacing: 3) { + Text("TOP HEARD") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.white.opacity(0.55)) + + if heard.isEmpty { + Text("Nothing heard") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white.opacity(0.45)) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + ForEach(Array(heard.prefix(4))) { node in + readoutHeardRow(node) + } + } + } + } + .padding(.horizontal, panelHorizontalInset) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .dynamicTypeSize(.small ... .large) + .opacity(client.isStale ? 0.5 : 1.0) + // Snapshot replacement may arrive with an animated transaction from an + // ancestor. Readout state changes are discrete; only the native precise + // countdown updates between them at full luminance. + .transaction { $0.animation = nil } + } + + /// One full-width row is affordable without a basemap and gives the settled + /// type dot, hex identity and quality figure enough size for arm's-length + /// reading even when a six-character hash forces the overlay into one column. + private func readoutHeardRow(_ node: WatchHeardNode) -> some View { + HStack(spacing: 5) { + Circle() + .fill(Color(node.typeColor)) + .frame(width: 8, height: 8) + Text(node.id) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(.white) + Spacer(minLength: 6) + if let snr = node.snr { + Text(snr, format: .number.precision(.fractionLength(1))) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(node.snrColor.map(Color.init) ?? .white) + .frame(width: 42, alignment: .trailing) + } + } + .lineLimit(1) + } + + private func latchBottomSafeAreaInset(_ inset: CGFloat) { + guard bottomSafeAreaInset == 0, inset > 0 else { return } + bottomSafeAreaInset = inset + } + + private func noteTopSafeAreaInset(_ inset: CGFloat) { + guard inset.isFinite else { return } + currentTopSafeAreaInset = max(0, inset) + + // Only the full-luminance readout is the approved reference; the map extends + // beneath top chrome and reports a different inset. Keep its largest value + // because the navigation bar settles upward after installing its toolbar. + // This intentionally differs from the bottom inset's first-nonzero latch: + // the bottom value drives padding and can feed back into its measurement, + // while the top value comes from the parent navigation host and drives + // padding only inside its readout child, which cannot change that inset. + guard !isLuminanceReduced, !showsMap, inset > 0 else { return } + latchedTopSafeAreaInset = max(latchedTopSafeAreaInset, inset) + } + + /// Phase and Top Heard in one panel. + /// + /// Rows are `[type dot] [hex ID] [SNR]`. The hex path hash is the identity, + /// because a 1-byte hash frequently cannot be resolved to a single repeater; + /// a name is appended only when the phone could resolve it unambiguously. + private var statusPanel: some View { + Button { + // Only a sheet placement has anything to open. With the list on its own + // page this was presenting a duplicate of a page that already exists — + // and because the panel sits across the bottom of the map, where an + // upward page swipe begins, it fired on swipes the wearer meant for the + // pager and stranded a modal over the next page. + guard settings.nodeListPlacement == .sheet else { return } + showingNodes = true + } label: { + VStack(alignment: .leading, spacing: 3) { + timerBar + + if heard.isEmpty { + Text("Nothing heard") + .font(.system(size: 10)) + .foregroundStyle(.white.opacity(0.45)) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + // Two columns are the useful glance layout even on 40 mm, so type + // shrinks against the same width model as the actual row. Only a + // six-character zone that would cross the 7 pt legibility floor gets + // one column; truncating the ID is not an option because it is the + // repeater's identity. + if twoHeardColumnsFit { + HStack(alignment: .top, spacing: columnGap) { + heardColumn(Array(heard.prefix(2))) + heardColumn(Array(heard.dropFirst(2))) + } + } else { + VStack(alignment: .leading, spacing: 2) { + ForEach(heard) { heardRow($0) } + } + } + } + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .frame(maxWidth: .infinity, alignment: .leading) + // Bright basemap labels bleed through flat translucency and fight the + // SNR digits; material removes that competing detail on the map only. + .background(.ultraThinMaterial, in: .rect(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(.white.opacity(0.12), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + // A HUD, not body copy: fixed sizes keep it from swallowing the map at + // large accessibility text sizes. The scrollable detail list is where the + // wearer's text-size setting is honoured. + .dynamicTypeSize(.small ... .large) + .opacity(client.isStale ? 0.5 : 1.0) + } + + /// A stable gutter keeps the width equation and the rendered columns in + /// agreement; changing it by device size would silently spend the room the + /// font calculation just recovered on the smallest watch. + private var columnGap: CGFloat { 8 } + + /// One column of heard rows. + private func heardColumn(_ nodes: [WatchHeardNode]) -> some View { + VStack(alignment: .leading, spacing: 2) { + ForEach(nodes) { heardRow($0) } + } + } + + /// `[type dot] [hex ID] [SNR]`, sized to its content. + /// + /// Content-sized on purpose so the row's intrinsic measurement stays equal + /// to the width model that chooses the font and column count. + private func heardRow(_ node: WatchHeardNode) -> some View { + HStack(spacing: 3) { + Circle() + .fill(Color(node.typeColor)) + .frame(width: 6, height: 6) + Text(node.id) + .font(.system(size: rowFontSize, weight: .semibold, design: .monospaced)) + .foregroundStyle(.white) + if let snr = node.snr { + Text(snr, format: .number.precision(.fractionLength(1))) + .font(.system(size: rowFontSize, weight: .semibold, design: .monospaced)) + .foregroundStyle(node.snrColor.map(Color.init) ?? .white) + // Fixed width so SNRs line up down a column despite varying digits. + .frame(width: rowFontSize * 3.1, alignment: .trailing) + } + } + .lineLimit(1) + .fixedSize() + } + + /// The phase as a depleting bar with its remaining time. + /// + /// The bar drains right to left from `phaseEndsAt` and `phaseDurationMs`, + /// both absolute, so it is correct without per-second updates and correct + /// when the app opens midway through a phase. A stale payload replaces the + /// whole row: a bar that keeps draining against a deadline the phone has + /// stopped confirming is worse than no bar. + @ViewBuilder + private var timerBar: some View { + if client.isStale, let receivedAt = client.receivedAt { + HStack(spacing: 3) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 8)) + Text(receivedAt, style: .relative) + .font(.system(size: 10, weight: .medium)) + Text("old") + .font(.system(size: 10)) + Spacer(minLength: 0) + } + .foregroundStyle(.orange) + .lineLimit(1) + } else if let snapshot { + WatchPhaseBar(snapshot: snapshot, isLuminanceReduced: isLuminanceReduced) + .frame(height: 15) + } + } + + /// Largest size allowed by the hash-length ladder. The watch now permits one + /// point more than the phone-derived sizes: it is read at arm's length in + /// motion, and the width solver still prevents that legibility gain from + /// making an intrinsic row overflow its column. + private var rowFontSizeCap: CGFloat { + let widest = heard.map(\.id.count).max() ?? 2 + if widest > 4 { return 10 } + if widest > 2 { return 11 } + return 12 + } + + /// Font size that leaves two intrinsic rows safely inside their columns. + /// The constants mirror `heardRow`: dot and gaps consume 12 pt, semibold + /// monospaced IDs advance closer to 0.62 em than the nominal 0.6, and the + /// aligned SNR owns 3.1 em. Two points of slack make measurement error land + /// on smaller type rather than a wider panel that defeats its edge clearance. + private var unconstrainedRowFontSize: CGFloat { + let idChars = CGFloat(heard.map(\.id.count).max() ?? 2) + let columnWidth = (panelContentWidth - columnGap) / 2 - 2 + return (columnWidth - 12) / (0.62 * idChars + 3.1) + } + + /// Eight points is the new glanceability floor for two columns. Without it, + /// the extra width makes six-character IDs on 40 mm barely “fit” at about + /// seven points and regress from the clearer single-column treatment. + private var twoHeardColumnsFit: Bool { unconstrainedRowFontSize >= 8 } + + private var rowFontSize: CGFloat { + guard twoHeardColumnsFit else { return rowFontSizeCap } + return min(unconstrainedRowFontSize.clamped(to: 8...12), rowFontSizeCap) + } + + private var heard: [WatchHeardNode] { snapshot?.geo.heard ?? [] } + + @ViewBuilder + private var recenterButton: some View { + if !isFollowing, fix != nil { + Button { + recenterIfFollowing(force: true) + } label: { + Image(systemName: "location.fill") + .font(.system(size: 10)) + } + .buttonStyle(.borderless) + .padding(4) + .background(.black.opacity(0.5), in: Circle()) + } + } + + // MARK: - Map + + private var map: some View { + // Pan consumes the vertical gesture the page shell needs, while the Crown + // remains the deliberate zoom control. Once drag input is absent, centre + // drift cannot honestly identify a pan — MapKit and Crown zoom can both + // produce it — so follow has no distance-based suspension path to misfire. + Map(position: $camera, interactionModes: [.zoom]) { + linkLines + pingMarkers + repeaterPins + fixMarker + } + .mapStyle(.standard) + // Tell MapKit what is covering the map, rather than hand-shifting the + // camera to compensate. Its own centre then *is* the centre of the band + // the wearer can actually see, so the fix lands there without a correction + // pass. A Crown zoom holds it there across the useful range — measured + // drift is 0.000000 degrees from 0.0002 up to about 21 degrees of span — + // but *not* past roughly 22 degrees, where MapKit shifts the centre north + // and leaves it there. `recentreAfterUserZoom` repairs that; the framing + // here is not what breaks. + // + // Both edges matter. Insetting only the bottom centres the fix in + // `0...panelTop`, which still includes the toolbar strip, and the puck then + // reads high — obviously so on 46 mm, where the chrome is a smaller + // fraction of the display. + .safeAreaPadding(.top, currentTopSafeAreaInset) + .safeAreaPadding(.bottom, panelCameraInset) + .onMapCameraChange(frequency: .onEnd) { context in + // `.onEnd` matters: the correction below must never run mid-gesture, or + // it would fight the Crown while the wearer is still turning it. + noteRenderedRegion(context.region, cameraCentre: context.camera.centerCoordinate) + } + // The shell's navigation host supplies the system toolbar placement but + // must not buy it by shortening the basemap. Only MapKit extends under that + // top chrome; the overlay remains in the safe content region, keeping its + // inset recentre button below the system toolbar controls. + .ignoresSafeArea(edges: [.top, .bottom]) + // Dim the basemap for the length of the hold rather than tearing it down. + // + // **The hold's real cost is lit pixels, not CPU.** An earlier comment + // priced it as "MapKit stays constructed and rendering", but watchOS + // suspends this app about a second into a wrist-down, so almost none of + // that rendering happens — what does happen is a bright basemap held on an + // OLED always-on display for the length of the hold, per glance, whether or + // not the app is scheduled to draw it. Apple's Always-On + // guidance names this directly: "if you display rich images or large areas + // of color, consider removing the images and using dimmed colors." + // + // A scrim buys that back without giving up the hold, which exists to stop + // the map flickering out from under a wearer who is still looking at it. + // + // **Value, never presence.** An overlay that appeared at the dim would + // re-identify the subtree beneath it and rebuild all of MapKit at exactly + // the wrong moment — the defect `pageContent`'s background note records. + // This one is always in the tree and only its opacity changes. + // **0.70, and the number is measured rather than chosen by eye.** A watch + // powerlog gives content brightness per sample as `AvgAPL`, and across a + // 90-minute walk the surfaces separate cleanly: a bright map sits at APL + // 135, this scrim plus Always-On took it to 72, and the readout that + // eventually replaces it sits at 30.5 in the same dim state. So the held + // map was still 2.4x the readout's content brightness — on an OLED, where + // power tracks lit pixels, that is the cost the hold was paying. + // + // Luminance scales as `basemap * (1 - opacity)`, and the numbers agree: 72 + // at 0.45 implies an unscrimmed 131, against a measured 135. Solving for + // the readout's 30.5 plus a little margin gives 0.70. + // + // **This is the lever, and the hold's duration is not.** Measured on the + // same walk: 63 map episodes, median 32.7 s, against the ~17.9 s this hold + // is supposed to cap them at — because the ticker below is throttled to + // roughly one wake a minute under Always On, so the readout returns when + // watchOS gets round to it and not when `dimmedMapHold` expires. Shortening + // the constant would not shorten the episodes; darkening the pixels they + // spend does. See `dimmedMapHoldTicker`, which already predicted the + // overshoot this measures. + .overlay { + Color.black + .opacity(isLuminanceReduced ? 0.70 : 0) + .ignoresSafeArea() + .allowsHitTesting(false) + } + } + + @MapContentBuilder + private var linkLines: some MapContent { + if settings.showLinks, let fix, let snapshot { + ForEach(linkedRepeaters(in: snapshot.geo)) { repeater in + MapPolyline(coordinates: [ + fix, + CLLocationCoordinate2D(latitude: repeater.lat, longitude: repeater.lon), + ]) + .stroke(Color(repeater.color).opacity(0.7), lineWidth: 1.5) + } + } + } + + /// Heard identities are path-hash prefixes, not API database IDs. Resolve + /// against the full hex carried by each pin, and require one match per + /// prefix: a line asserts a real radio path, so an ambiguous line is worse + /// than drawing none. The phone performs the same check against the full + /// catalogue before sending; repeating it here keeps malformed or older + /// payloads from turning ambiguity into a visual claim. + private func linkedRepeaters(in geo: WatchGeo) -> [WatchRepeater] { + var resolved = [WatchRepeater]() + var seen = Set() + + for rawPrefix in geo.linkedRepeaterIds { + let prefix = rawPrefix.uppercased() + guard !prefix.isEmpty else { continue } + let matches = geo.repeaters.filter { + $0.hexId?.uppercased().hasPrefix(prefix) == true + } + guard matches.count == 1, seen.insert(matches[0].id).inserted else { continue } + resolved.append(matches[0]) + } + return resolved + } + + @MapContentBuilder + private var pingMarkers: some MapContent { + if let snapshot { + ForEach(snapshot.geo.pings) { ping in + Annotation("", coordinate: CLLocationCoordinate2D(latitude: ping.lat, longitude: ping.lon)) { + // The phone's marker style is a user preference the watch does not + // receive, so mirror its default dot: a filled circle with a soft + // white border. The phone's shadow is omitted at this wrist scale. + Circle() + .fill(Color(ping.color)) + .frame(width: 6, height: 6) + .overlay( + Circle() + .strokeBorder(.white.opacity(0.6), lineWidth: 0.75) + ) + } + .annotationTitles(.hidden) + } + } + } + + @MapContentBuilder + private var repeaterPins: some MapContent { + if let snapshot { + ForEach(snapshot.geo.repeaters) { repeater in + Annotation("", coordinate: CLLocationCoordinate2D(latitude: repeater.lat, longitude: repeater.lon)) { + RepeaterPin(color: Color(repeater.color), highlighted: repeater.heardThisCycle) + } + .annotationTitles(.hidden) + } + } + } + + @MapContentBuilder + private var fixMarker: some MapContent { + // `displayedFix` for the position, `you` for the heading — the first is the + // eased rendering of the second's coordinate, and both must be present for + // the puck to mean anything. + if let displayedFix, let you = snapshot?.geo.you { + Annotation("", coordinate: displayedFix) { + // The phone's fix, not the watch's. Rendering it ourselves keeps the + // watch free of any location permission. + FixPuck(headingDeg: you.headingDeg) + } + .annotationTitles(.hidden) + } + } + + // MARK: - Camera + + /// How a follow step should move, or `nil` to cut. + /// + /// **Reduced luminance cuts, on the same rule as `WatchPhaseBar.drainsFill`.** + /// Always-On repaints roughly once a minute, so an eased camera there buys no + /// visible motion and spends MapKit render work anyway — and the dim hold + /// deliberately keeps this map on screen for `dimmedMapHold` seconds past the + /// dim, which is exactly the window where it would otherwise be animating for + /// nobody. Geo can still arrive in that window: `needsMapGeo` goes false at + /// the dim but `WatchBridgeService` only schedules suppression 15 s out. + /// + /// 0.7 s is chosen against the update cadence, not by feel. The phone's + /// movement gate is 15 m and its non-urgent throttle is 2 s, so follow steps + /// arrive about 11 s apart at walking pace and never closer than 2 s. A curve + /// comfortably inside that floor means one step finishes before the next is + /// retargeted, so the common case is a clean glide rather than a chain of + /// interrupted animations. + private var followAnimation: Animation? { + guard !isLuminanceReduced else { return nil } + return .easeInOut(duration: 0.7) + } + + /// A step larger than this is treated as a resumed stream, not as travel. + /// + /// Updates are at least 2 s apart, so even a sprint moves well under this + /// between them. Anything further means delivery was interrupted — a + /// suppressed geo claim, a wrist-down, a dropped session — and sliding the + /// map across that gap would draw a whoosh through territory the wearer never + /// walked. Cut instead, which is what this page did for every step before. + private static let followAnimationCeilingM: CLLocationDistance = 100 + + /// Move the puck and the camera together, so a follow step slides the world + /// rather than cutting to it. + /// + /// The 15 m movement gate on the phone means a walking wearer gets one step + /// every ~11 s, and at the 250 m default zoom 15 m is around 12 pt of screen — + /// large enough to read as a jump rather than as motion. + /// + /// Both assignments have to sit in the same `withAnimation` for the reason + /// `displayedFix` exists at all. `recenterIfFollowing` reaches `applyRegion` + /// with `animated: false`, which here means "add no animation of your own" — + /// the assignment inherits this transaction instead. Its own 0.25 s ease is + /// still right for the paths that pass `animated: true`, which are discrete + /// wearer-visible events rather than tracking. + private func trackFix() { + let previous = displayedFix + guard let animation = followAnimation, + let previous, + let fix, + CLLocation(latitude: previous.latitude, longitude: previous.longitude) + .distance(from: CLLocation(latitude: fix.latitude, longitude: fix.longitude)) + <= Self.followAnimationCeilingM + else { + // Covers a cut for every reason: dimmed, a first fix with nothing to + // travel from, geo withdrawn, or a step too large to be travel. Clearing + // to `nil` matters — a stale rendered position left standing would become + // the origin of the next animation. + displayedFix = fix + recenterIfFollowing() + return + } + + withAnimation(animation) { + displayedFix = fix + // Follow off still animates the puck: it slides to its true place across + // a map the wearer chose to hold still, which is the same improvement for + // the case where the camera is deliberately not tracking. + recenterIfFollowing() + } + } + + /// `animated` defaults to following `force` — a tap is the case that wants to + /// be shown to the wearer. The panel settle passes it explicitly, because it + /// is not a forced recentre (Follow off must still mean Follow off) and yet it + /// is a deliberate, isolated change that reads better eased than snapped. + private func recenterIfFollowing(force: Bool = false, animated: Bool? = nil) { + // Every early return here leaves `camera` untouched, and an untouched + // camera can still be `.automatic` — which fits every annotation and has + // been measured rendering 34.4 degrees, about 3,800 km. Log which clause + // bailed: "no fix" and "follow off" are indistinguishable in the span log + // and want different fixes. + #if DEBUG + if !(showsMap && (force || isFollowing) && fix != nil) { + WakeLog.note( + "recenter-skipped showsMap \(showsMap) following \(isFollowing) " + + "fix \(fix == nil ? "nil" : "yes") force \(force)" + ) + } + #endif + guard showsMap, force || isFollowing, let fix else { return } + // Centre on the fix itself. The camera inset already accounts for the + // panel, so no compensating shift is needed and nothing has to be + // corrected after the map reports back. + // + // A tap is a rare, explicit request to move the map, so animation shows the + // wearer what their action changed. Automatic follow passes `false` and is + // eased by its caller instead: `trackFix` wraps this call and the puck's + // position in one transaction, which is the only arrangement where the + // camera can move without the puck wandering away from centre first. + applyRegion(center: fix, animated: animated ?? force) + } + + /// Guarantee this native map has a region of ours, whatever `recenterIfFollowing` + /// decided. + /// + /// **`.automatic` is a terminal state, and this is what ends it.** Every early + /// return in `recenterIfFollowing` leaves `camera` untouched, and an untouched + /// camera can still be `.automatic` — which fits every annotation and was + /// measured on a Series 9 rendering 34.364390 degrees of latitude, about + /// 3,800 km. Nothing else in this file assigns a region, so once the camera + /// landed there only a manual Crown zoom escaped it: twenty consecutive + /// callbacks at that span, across five separate wrist raises. + /// + /// The wake log is specific about when it happens. Wakes preceded only by + /// follow updates restored perfectly — first callback `drift 0.0%`, every + /// time. The one wake that came back continental was the one preceded by a + /// burst of Crown zooming, which suggests the interaction leaves `camera` in a + /// state that does not survive the subtree teardown. This does not depend on + /// that being the mechanism: it asserts a region regardless of how the camera + /// got where it is. + /// + /// Follow governs *tracking*, not whether a region is ever asserted. Turning + /// Follow off must not be able to strand the map at a continental span. + private func anchorCameraIfNeeded() { + guard showsMap, !hasAssertedRegion, let center = anchorCenter else { return } + applyRegion(center: center, animated: false) + } + + /// Where a map with no follow-driven centre should open. + /// + /// Ordered by how well each source reflects what the wearer last saw. With + /// Follow off a *recent* centre wins over the live fix on purpose — restoring + /// the view they left is the whole point, and jumping to the fix is precisely + /// the tracking they turned off. + /// + /// The live fix outranks a stale one because this map cannot pan + /// (`interactionModes: [.zoom]`), so opening on a centre the wearer has since + /// travelled away from strands them somewhere they can only zoom. A stale + /// centre is still preferred over nothing: it is a real place the wearer once + /// was, which beats `.automatic` fitting every annotation at 3,800 km. + private var anchorCenter: CLLocationCoordinate2D? { + if isFollowing, let fix { return fix } + if let programmaticCenter { return programmaticCenter } + return settings.freshMapCenter ?? fix ?? settings.lastMapCenter + } + + /// The one place `camera` is assigned, so every path records the centre and + /// marks the map anchored. + /// + /// `span` defaults to the persisted zoom, which is right for every caller + /// except the post-zoom correction: that one has to carry the span MapKit + /// just rendered, or correcting the centre would also undo the wearer's zoom. + private func applyRegion( + center: CLLocationCoordinate2D, + span: MKCoordinateSpan? = nil, + animated: Bool + ) { + #if DEBUG + // The other half of the boundary probe: the two handlers above say what + // changed, this says whether the camera actually moved because of it. + if isNearPhaseBoundary { + WakeLog.note( + String( + format: "boundary camera %.6f,%.6f span %.6f animated %@", + center.latitude, + center.longitude, + (span ?? currentSpan).latitudeDelta, + animated ? "yes" : "no" + ) + ) + } + #endif + programmaticCenter = center + hasAssertedRegion = true + settings.noteMapCenter(center) + let region = MKCoordinateRegion(center: center, span: span ?? currentSpan) + + // DO NOT re-add a "skip when the region is unchanged" guard here. One was + // tried and reverted: it bought no measurable wake latency (0.43/0.46/0.47 s + // against a 0.15 s sampling floor) and it removed a repair. This assignment + // is not merely redundant — it is the only thing that re-asserts our region + // over whatever the camera drifted to, including `.automatic`, whose + // annotation fit spans a continent. Adam raised his wrist to a map showing + // the whole United States while that guard was installed. + if animated { + withAnimation(.easeInOut(duration: 0.25)) { + camera = .region(region) + } + } else { + // "No animation of our own", not "no animation". First placement, the + // anchor and the post-zoom correction all reach here outside any + // transaction and cut, which is what they want. A follow step reaches it + // from inside `trackFix`'s `withAnimation` and inherits that curve, so + // the camera travels on exactly the same one as the puck. + camera = .region(region) + } + } + + #if DEBUG + /// `positionedByUser` is included because a Crown zoom is the one interaction + /// that precedes the stuck camera in the wake log, and it is the only public + /// signal that MapKit rather than we last moved the camera. + private var cameraStateDescription: String { + "automatic \(camera == .automatic) byUser \(camera.positionedByUser) " + + "anchored \(hasAssertedRegion) fix \(fix == nil ? "nil" : "yes") " + + "following \(isFollowing)" + } + #endif + + /// MapKit fits longitude to the watch's aspect ratio, so latitude is the one + /// independent zoom value. After the one-time defaults migration it starts + /// at 0.00225 degrees, about 250 m north-south, and later launches reuse the + /// wearer's Crown setting. + private var currentSpan: MKCoordinateSpan { + MKCoordinateSpan( + latitudeDelta: settings.mapLatitudeDelta, + longitudeDelta: settings.mapLatitudeDelta + ) + } + + /// Track the region MapKit actually rendered, so a Digital Crown zoom is not + /// thrown away on the next follow update. + private func noteRenderedRegion( + _ region: MKCoordinateRegion, + cameraCentre: CLLocationCoordinate2D + ) { + // Latched before anything below can assign the camera and clear it. + let wasPositionedByUser = camera.positionedByUser + + // Deferred because the span write-back below has four early returns and the + // centre needs repairing on every one of them — the northward shift arrives + // on a zoom-out large enough that the write-back may well bail. + defer { + recentreAfterUserZoom( + wasPositionedByUser: wasPositionedByUser, + renderedSpan: region.span + ) + } + + // A wearer who works the Crown on a map we never anchored has stated a + // camera preference, and it outranks our seed. Without this, the sequence + // "launch with no fix -> wearer zooms -> first fix arrives" ends with + // `anchorCameraIfNeeded` replacing their zoom with the persisted span, + // because `hasAssertedRegion` was still false. Marking it here retires the + // anchor without asserting anything, which is exactly the intent: the map + // is positioned, just not by us. + if wasPositionedByUser { + hasAssertedRegion = true + } + + #if DEBUG + if wasPositionedByUser, let fix { + // Quantifies the zoom-anchor drift. The wearer reported that zooming in + // loses the centre faster than zooming out; this is the number behind it, + // and it should read near zero once the correction below has run. + WakeLog.note( + String( + format: "user-zoom drift lat %.6f lon %.6f span %.6f", + cameraCentre.latitude - fix.latitude, + cameraCentre.longitude - fix.longitude, + region.span.latitudeDelta + ) + ) + } + #endif + + // `.automatic` can report its annotation fit even after we assign our first + // region, so `programmaticCenter != nil` proves only that a request was + // made, not that MapKit rendered it. Persist nothing until the rendered + // span confirms the request; later deviations are Crown zooms and remain + // eligible for the normal write-back below. + guard programmaticCenter != nil else { return } + + let rendered = region.span.latitudeDelta + let requested = settings.mapLatitudeDelta + guard rendered.isFinite, requested.isFinite, requested > 0 else { return } + + #if DEBUG + WakeLog.note( + String( + format: "span rendered %.6f requested %.6f confirmed %@ drift %.1f%%", + rendered, requested, hasConfirmedRequestedSpan ? "Y" : "N", + abs(rendered - requested) / requested * 100 + ) + ) + #endif + + guard hasConfirmedRequestedSpan else { + let relativeDifference = abs(rendered - requested) / requested + if relativeDifference <= Self.spanConfirmationTolerance { + hasConfirmedRequestedSpan = true + } + return + } + + // Only on a real change. Every follow update produces a camera change, and + // writing an identical value would persist and invalidate on each one, + // re-rendering the map for nothing. + guard abs(rendered - requested) / requested > 0.01 else { return } + settings.mapLatitudeDelta = rendered + } + + /// Put the fix back at the centre after the wearer has zoomed. + /// + /// **What actually drifts, measured on a Series 9.** A Crown zoom holds the + /// camera centre exactly — drift is 0.000000 degrees through spans of 0.0002 + /// up to about 21 degrees. Past roughly 22 degrees MapKit moves the centre + /// *north* and does not put it back: measured jumps of 0.074080 degrees + /// (8.2 km) at a 24.5-degree span and 0.126004 degrees (14.0 km) at a + /// 25.0-degree span. The shift is **sticky** — it survives every later zoom, + /// including a zoom all the way back in to 0.000229, so the wearer ends up + /// looking at a point 8-14 km north of themselves at street level. Only a + /// wrist drop, which rebuilds the map, cleared it. + /// + /// That is exactly what was reported: "zoom out seems to stay centered + /// initially, but zoom didn't stay centered (missed to the north, + /// significantly). Re-centered after drop/raise." + /// + /// **This function was once deleted, and the deletion was a mistake worth + /// recording.** An earlier reading of the same probe showed drift pinned at + /// zero and concluded there was nothing to repair. That run had this + /// correction installed: the probe samples at the start of the *next* camera + /// change, so it was reading a centre this function had just repaired. The + /// zero was the repair working. Before removing it again, remove it *first* + /// and re-measure — a metric taken through a repair cannot evaluate it. + /// + /// **The span is preserved, not re-asserted.** Using `currentSpan` here would + /// pull the zoom back to the persisted (and clamped) value on every turn of + /// the Crown, which reads as the map refusing to zoom. Only the centre moves. + /// + /// **Termination.** Gated on `positionedByUser`, which MapKit sets for an + /// interactive change and our own assignment clears, so this can trigger + /// exactly one more camera change and that one does not re-enter. Confirmed + /// on device: `byUser true` appears only after a real Crown zoom. + /// + /// Following only — with Follow off the camera is the wearer's. + private func recentreAfterUserZoom(wasPositionedByUser: Bool, renderedSpan: MKCoordinateSpan) { + guard wasPositionedByUser, isFollowing, let fix else { return } + // Unconditional, like every other assertion in this file. When the centre + // has not drifted this re-asserts the same region, which is the cheap case; + // see `applyRegion` for what a "skip when unchanged" guard cost last time. + applyRegion(center: fix, span: renderedSpan, animated: false) + } + +} + +/// A phase-scoped progress animation rather than a one-second render clock. +/// +/// `Text(timerInterval:)` owns its countdown without invalidating this view. +/// The fill is set once from the absolute deadline and driven to zero by a +/// single transform animation; one sleeping task wakes at the deadline solely +/// to retire the countdown and dim a claim the phone has not refreshed. +private struct WatchPhaseBar: View { + let snapshot: WatchSnapshot + /// Passed in rather than read from the environment so the DEBUG dim + /// overrides on `MapPage` reach it — the whole panel is captured through + /// them, and a bar that kept animating would be the one thing that did not. + let isLuminanceReduced: Bool + + @State private var remainingFraction: CGFloat + @State private var deadlineLapsed: Bool + + init(snapshot: WatchSnapshot, isLuminanceReduced: Bool) { + self.snapshot = snapshot + self.isLuminanceReduced = isLuminanceReduced + let now = Date() + _remainingFraction = State( + initialValue: CGFloat(snapshot.phaseRemainingFraction(at: now) ?? 0) + ) + _deadlineLapsed = State( + initialValue: snapshot.phaseEndsAt.map { $0 <= now } ?? false + ) + } + + /// Whether the fill should be animated at all, as opposed to snapped to the + /// deadline's current truth and left there. + /// + /// **Reduced luminance stops it.** Always-On updates the screen at roughly + /// one frame a minute, so a drain animation there buys no visible motion and + /// spends energy anyway; Apple's Always-On guidance is to pause animations + /// and drop subsecond work while the app is inactive. The map now stays + /// constructed for up to `dimmedMapHold` seconds past the dim, which is + /// exactly when this bar would otherwise keep animating over a screen nobody + /// can see move. + private var drainsFill: Bool { + #if DEBUG + // A/B harness for the drain's energy cost, under Settings -> Instruments. + if WatchSettings.debugFreezesTimerBar { return false } + #endif + return !isLuminanceReduced + } + + private var phaseKey: PhaseAnimationKey { + PhaseAnimationKey( + endsAtMs: snapshot.phaseEndsAtMs, + durationMs: snapshot.phaseDurationMs, + drainsFill: drainsFill + ) + } + + /// Build the native timer's range at render time so a deadline crossing + /// between the sleeping task and a body update can never form `now...past`. + private var activeCountdownRange: ClosedRange? { + let now = Date() + guard !deadlineLapsed, let endsAt = snapshot.phaseEndsAt, endsAt > now else { + return nil + } + return now...endsAt + } + + var body: some View { + ZStack(alignment: .leading) { + Capsule().fill(.white.opacity(0.16)) + Capsule() + .fill(snapshot.pingColor.map(Color.init) ?? .accentColor) + // **A transform, not a width.** Animating `frame(width:)` re-runs + // SwiftUI's layout for the whole panel on every frame of a drain that + // lasts the entire phase; a leading-anchored scale is one affine + // transform handed to the render server and left alone. + // + // **Scale the mask, not the capsule.** Scaling the capsule itself + // squashes its end caps, so the bar started rounded and finished with + // square ends — visible on the wrist at 15 pt tall, where an earlier + // comment here guessed it would not be. Masking keeps the capsule's + // own geometry untouched, so the leading cap stays round and only the + // trailing edge becomes a flat cut, and the animated property is still + // a single affine transform on a solid shape. + .mask(alignment: .leading) { + Rectangle() + .scaleEffect(x: remainingFraction, y: 1, anchor: .leading) + } + } + .overlay { + HStack { + // A missing deadline describes a durable state. A passed one is only + // the phone's last claim, so keep it visible but no longer assert it. + Text(snapshot.phaseTitle) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.white.opacity(deadlineLapsed ? 0.45 : 1)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 4) + if let range = activeCountdownRange { + // Fixed width because the native timer otherwise reserves room for + // its widest possible value and starves the title. + Text(timerInterval: range, countsDown: true) + .font(.system(size: 11, weight: .bold).monospacedDigit()) + .foregroundStyle(.white) + .frame(width: 38, alignment: .trailing) + } + } + // The fill slides under both labels, so a shadow keeps them readable + // against the filled and empty parts of the track. + .shadow(color: .black.opacity(0.7), radius: 1.5) + .padding(.horizontal, 6) + } + .task(id: phaseKey) { + await runPhaseAnimation() + } + } + + @MainActor + private func runPhaseAnimation() async { + let now = Date() + let fraction = CGFloat(snapshot.phaseRemainingFraction(at: now) ?? 0) + let lapsed = snapshot.phaseEndsAt.map { $0 <= now } ?? false + + // A replacement phase must start at its true current fraction, not animate + // from the previous phase's endpoint before beginning its own drain. This + // is also what makes the dim honest: the task re-runs when `drainsFill` + // flips, so the frozen bar is snapped to the deadline's truth rather than + // stranded wherever the cancelled animation happened to be. + // + // **A zero-duration animation, not `disablesAnimations`.** Suppressing the + // transaction leaves the running drain in place, and interrupting a + // transform animation reverts the layer to its model value — measured in + // the simulator as a bar that snapped back to *full* at the dim and stayed + // there, while the countdown beside it ran on. Replacing the animation + // rather than opting out of one is what actually ends it. + // + // **And replacing it requires the value to actually change.** While a drain + // runs, the *model* value here is already 0: `withAnimation` set it there at + // the start and is interpolating only the presentation toward it. So this + // assignment is a no-op precisely when the new truth is also 0 — which is + // the stopped-session case — and the in-flight drain survives untouched. + // Adam saw exactly that on the wrist: the title changed to "Ready" while + // the fill kept draining to the old deadline. The dim never showed it + // because a dim mid-phase has a non-zero truth, so the assignment was a + // real change and did replace the animation. + // + // Hence the nudge: one zero-duration transaction to a value that is + // certainly different, which SwiftUI must take and which abandons the + // animation in flight, then the real one to the truth. Both are + // zero-duration, so neither is visible. + // The nudge must land in its own transaction. SwiftUI batches state changes + // made in one run-loop turn, so a nudge and the real assignment written back + // to back coalesce into a single change that nets to nothing — the very + // no-op being escaped. `Task.yield()` separates them, which is the same + // reason the drain below already yields before it starts. + if remainingFraction == fraction { + withAnimation(.linear(duration: 0)) { + remainingFraction = fraction > 0.5 ? fraction - 0.001 : fraction + 0.001 + } + await Task.yield() + } + withAnimation(.linear(duration: 0)) { + remainingFraction = fraction + deadlineLapsed = lapsed + } + + #if DEBUG + // Measure instead of reasoning about it: the stopped-session drain has now + // survived one reasoned fix, so record what this task actually sees. If no + // line appears on a stop, `phaseKey` did not change and the task never + // re-ran; if one appears with `endsAt nil fraction 0.000` while the fill is + // still visibly draining, the animation is outliving the state and no + // assignment here will end it. + WakeLog.note( + String( + format: "bar %@ endsAt %@ duration %@ fraction %.3f lapsed %@ drains %@", + snapshot.phaseTitle, + snapshot.phaseEndsAtMs.map { String(format: "%.0f", $0) } ?? "nil", + snapshot.phaseDurationMs.map(String.init) ?? "nil", + fraction, + lapsed ? "yes" : "no", + drainsFill ? "yes" : "no" + ) + ) + #endif + + guard let endsAt = snapshot.phaseEndsAt else { return } + let remaining = endsAt.timeIntervalSince(now) + guard remaining > 0 else { return } + + if drainsFill { + await Task.yield() + withAnimation(.linear(duration: remaining)) { + remainingFraction = 0 + } + } + + // Retiring the deadline is one wake per phase, not per frame, so it stays + // in place while dimmed. Losing it would leave a countdown asserting a + // claim the phone has stopped confirming. + do { + try await Task.sleep(for: .seconds(remaining)) + } catch { + return + } + guard !Task.isCancelled else { return } + deadlineLapsed = true + } + + private struct PhaseAnimationKey: Hashable { + // The drain depicts time, not its caption. Skip mode can change the title + // against the same timer; including it here would cancel, snap and restart + // an otherwise continuous bar every time the label flips. + let endsAtMs: Double? + let durationMs: Int? + /// Luminance participates only to stop and resume the drain. It belongs in + /// the key so the dim cancels the running animation rather than leaving it + /// interpolating against a screen that updates once a minute. + let drainsFill: Bool + } +} + +extension Comparable { + fileprivate func clamped(to limits: ClosedRange) -> Self { + min(max(self, limits.lowerBound), limits.upperBound) + } +} + +extension View { + /// Take a control out of service while the display is dimmed, without taking + /// it out of the view tree. + /// + /// Presence is what must not change: wrapping toolbar items in an `if` alters + /// the toolbar's structure, which re-hosts the page and rebuilds everything + /// under it — measured on hardware as a full MapKit teardown 0.13 s into + /// every dim. Every modifier here is a value change instead. + /// + /// All three are needed. Opacity hides it, hit testing stops a touch, and + /// `disabled` plus `accessibilityHidden` stop an assistive technology + /// activating a control nobody can see. The trailing toolbar control can be + /// Start, which transmits on one tap with no confirmation step. + fileprivate func hiddenWhileDimmed(_ dimmed: Bool) -> some View { + opacity(dimmed ? 0 : 1) + .allowsHitTesting(!dimmed) + .disabled(dimmed) + .accessibilityHidden(dimmed) + } +} + +/// The approved full-screen phase treatment with cadence chosen independently +/// from its layout. Always-On updates too slowly to promise seconds, while a +/// wearer-selected readout at full luminance can let the native timer provide +/// a precise countdown without a one-second SwiftUI timeline. +private struct ReadoutPhase: View { + let snapshot: WatchSnapshot + let isLuminanceReduced: Bool + + @State private var deadlineLapsed: Bool + + init(snapshot: WatchSnapshot, isLuminanceReduced: Bool) { + self.snapshot = snapshot + self.isLuminanceReduced = isLuminanceReduced + _deadlineLapsed = State( + initialValue: snapshot.phaseEndsAt.map { $0 <= Date() } ?? false + ) + } + + private var phaseKey: PhaseKey { + PhaseKey( + endsAtMs: snapshot.phaseEndsAtMs, + isLuminanceReduced: isLuminanceReduced + ) + } + + /// **The native timer, in both luminance states. No coarse ladder, no + /// timeline.** + /// + /// This replaced a `TimelineView(.periodic(by: 60))` driving a + /// `"<15 sec"` / `"<30 sec"` / `"<1 min"` ladder, whose premise was that "a + /// seconds figure can be nearly a minute wrong while watchOS throttles + /// Always-On". **On hardware that premise is false**: the seconds in a + /// `Text(timerInterval:)` keep updating periodically under Always On, closely + /// enough to beat every rung of the ladder — which matters here because + /// `autoPingInterval` is 15, 30 or 60 seconds, so every phase lives inside + /// the range the ladder was coarsest about. + /// + /// **The simulator disagrees, and it is wrong.** With the Always On toggle on + /// watchOS 26 it renders the same construct as `9:––`, blanking the seconds + /// outright. That reading briefly justified keeping the ladder for sub-minute + /// phases, on the reasoning that `0:––` says nothing. Adam's wrist says + /// otherwise, and the wrist is the authority — this file has been caught by + /// simulator-only behaviour more than once. + /// + /// So the system does the work, more precisely than we did, and the per-dim + /// structural swap and the minute wake both go away. + var body: some View { + phase(at: Date()) + .task(id: phaseKey) { + await trackDeadline() + } + } + + @ViewBuilder + private func phase(at date: Date) -> some View { + // The clock as well as the flag. `trackDeadline` is gated to full + // luminance — it must not wake a dimmed app — so under Always On the flag + // never flips, and only this comparison can retire a deadline the phone has + // stopped confirming. Same rule as `isWithinDimmedMapHold`: every render + // that actually happens gets the right answer, whenever it happens. + let lapsed = deadlineLapsed || (snapshot.phaseEndsAt.map { $0 <= date } ?? false) + + VStack(alignment: .leading, spacing: 2) { + // Wrapping is intentional. The longest real phase names need two lines + // on 40 mm, and preserving every word matters more than uniform height. + Text(snapshot.phaseTitle) + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(.white.opacity(lapsed ? 0.45 : 1)) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + if !lapsed, let endsAt = snapshot.phaseEndsAt, endsAt > date { + // Marks the figure as an upper bound, which is exactly what it is under + // Always On. Measured on a Series 9: the system refreshes this text at + // a new phase, then again 12–18 s later, then about every 12 s after + // that — so between refreshes it reads high by up to that much. + // Remaining time only decreases, so "<" stays true across the whole + // gap, and an exact bound beats the ladder this replaced, which + // discarded up to 14 s of precision to say less. + // + // **Concatenation is safe here, and that was checked rather than + // assumed.** A probe rendering a plain timer, a concatenated one, and + // this exact value-driven form advanced together once the simulator + // left Always On. Worth recording that the check nearly read the other + // way: in Always On the simulator freezes *all* timer text, including a + // plain `Text(timerInterval:)`, so a first look suggested concatenation + // had broken the countdown when nothing was updating at all. + // The lift is not decoration. "<" is centred on the font's math axis, + // which sits below the optical centre of lining digits, so unshifted it + // reads low against "0:23" — Adam saw it immediately, and a @2x render + // measured it at 3.5 px low. + // + // **One point, chosen by eye over the geometric optimum.** The same + // render put the bounding-box centres closest at 2 pt (−0.5 px, against + // +1.5 px at 1 pt), but 2 looked high on the wrist. That is the usual + // gap between geometric and optical centring: a chevron is a wide, + // pointed glyph and the eye weights its extremes, so squaring the boxes + // overshoots. The measurement bounds the answer to 1 or 2; the wrist + // picks between them, and it picked 1 — which is also an even 2 device + // pixels at @2x. + // + // Tied to the 24 pt size above; re-measure if that changes. + (Text(isLuminanceReduced ? "< " : "").baselineOffset(1) + + Text(timerInterval: date...endsAt, countsDown: true)) + .font(.system(size: 24, weight: .bold).monospacedDigit()) + .foregroundStyle(.white) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + @MainActor + private func trackDeadline() async { + let now = Date() + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + deadlineLapsed = snapshot.phaseEndsAt.map { $0 <= now } ?? false + } + + // Never sleeps while dimmed: waking a suspended app to retire a caption + // nobody is reading is exactly the cost this readout exists to avoid. The + // render-time clock check in `phase(at:)` covers that state instead. + guard !isLuminanceReduced, let endsAt = snapshot.phaseEndsAt else { return } + let remaining = endsAt.timeIntervalSince(now) + guard remaining > 0 else { return } + do { + try await Task.sleep(for: .seconds(remaining)) + } catch { + return + } + guard !Task.isCancelled else { return } + deadlineLapsed = true + } + + private struct PhaseKey: Hashable { + let endsAtMs: Double? + let isLuminanceReduced: Bool + } +} + +// MARK: - Markers + +private struct FixPuck: View { + let headingDeg: Double? + + var body: some View { + ZStack { + Circle() + .fill(.blue) + .frame(width: 10, height: 10) + Circle() + .stroke(.white, lineWidth: 1.5) + .frame(width: 10, height: 10) + if let headingDeg { + Image(systemName: "location.north.fill") + .font(.system(size: 7)) + .foregroundStyle(.white) + .offset(y: -9) + .rotationEffect(.degrees(headingDeg)) + } + } + } +} + +private struct RepeaterPin: View { + let color: Color + let highlighted: Bool + + var body: some View { + ZStack { + if highlighted { + Circle() + .stroke(color, lineWidth: 1.5) + .frame(width: 14, height: 14) + } + Circle() + .fill(color) + .frame(width: 7, height: 7) + .overlay(Circle().stroke(.black.opacity(0.6), lineWidth: 0.5)) + } + } +} + +#if DEBUG +/// Wrist-raise timings, written to a file rather than only to the system log. +/// +/// Two failures pushed this out of `NSLog` alone. `log collect --device-udid` +/// **cannot reach an Apple Watch** — the watch connects over `localNetwork` and +/// that path wants a USB-attached device, so it fails with `Device not +/// configured (6)`. Console.app can stream it, but only live, so a run is lost +/// if streaming was not already started, and the operator has to copy text back +/// by hand. A file survives the app being suspended, terminated and relaunched, +/// and comes back whole: +/// +/// xcrun devicectl device copy from --device \ +/// --domain-type appDataContainer \ +/// --domain-identifier net.meshmapper.app.watchkitapp \ +/// --source Documents/wake-timings.log --destination . +/// +/// Lines still go to `NSLog` as well, so Console.app keeps working for anyone +/// who prefers watching it live. +enum WakeLog { + private static let name = "wake-timings.log" + + private static var url: URL? { + FileManager.default + .urls(for: .documentDirectory, in: .userDomainMask) + .first? + .appendingPathComponent(name) + } + + /// Appends one timestamped line. Opened and closed per write on purpose: a + /// held handle would not be flushed if watchOS killed the app mid-test, which + /// is the one moment this log exists to describe. + static func note(_ message: String) { + guard WatchSettings.debugLogsWakeTiming else { return } + let stamp = Date().timeIntervalSince1970 + NSLog("[wake-log] %@ at %f", message, stamp) + + guard let url else { return } + let line = String(format: "%.4f %@\n", stamp, message) + guard let data = line.data(using: .utf8) else { return } + + if let handle = try? FileHandle(forWritingTo: url) { + defer { try? handle.close() } + _ = try? handle.seekToEnd() + try? handle.write(contentsOf: data) + } else { + try? data.write(to: url) + } + } +} +#endif diff --git a/ios/MeshMapperWatch/MeshMapperWatchApp.swift b/ios/MeshMapperWatch/MeshMapperWatchApp.swift new file mode 100644 index 00000000..5d49818a --- /dev/null +++ b/ios/MeshMapperWatch/MeshMapperWatchApp.swift @@ -0,0 +1,44 @@ +import SwiftUI + +/// Entry point for the MeshMapper watchOS companion app. +/// +/// The watch is a mirror-and-remote for a session the iPhone owns: the phone +/// holds the BLE link to the MeshCore device and the GPS fix, and pushes +/// snapshots over WatchConnectivity. Nothing here drives a session on its own. +/// +/// The app is a vertical pager over a map, a node list, controls, and a debug +/// dump, with wrist-local layout preferences in settings. +@main +struct MeshMapperWatchApp: App { + @State private var client: WatchSessionClient + @State private var settings: WatchSettings + @Environment(\.scenePhase) private var scenePhase + + /// Built here rather than as two independent property initializers so both + /// the environment and the session client hold the *same* `WatchSettings`. + /// A second instance would keep its own copy of the haptic preference and + /// silence only one of them. + init() { + let settings = WatchSettings() + _settings = State(initialValue: settings) + _client = State(initialValue: WatchSessionClient(settings: settings)) + } + + var body: some Scene { + WindowGroup { + ContentView() + .environment(client) + .environment(settings) + .onAppear { client.refresh() } + // `onAppear` fires once, and watchOS suspends this app for the whole + // wrist-down interval, so it cannot speak for a resume. Reconciling + // here is what stops a glance landing on state the UI calls stale + // while the phone is available; `resume` decides on its own whether + // that costs a request. + .onChange(of: scenePhase) { previous, phase in + guard phase == .active, previous != .active else { return } + client.resume() + } + } + } +} diff --git a/ios/MeshMapperWatch/NodeListView.swift b/ios/MeshMapperWatch/NodeListView.swift new file mode 100644 index 00000000..d1529806 --- /dev/null +++ b/ios/MeshMapperWatch/NodeListView.swift @@ -0,0 +1,152 @@ +import SwiftUI + +/// Repeaters that answered the most recent ping, strongest first. +/// +/// The payload carries up to `MeshMapperWatchWire.maxHeard` rows, but this view +/// never shrinks type to fit them. It uses standard text styles so the wearer's +/// watch text-size setting is honoured and scrolls for whatever doesn't fit — +/// at default size that lands around 5–7 rows on a 45 mm, and at large +/// accessibility sizes it may be 3. That is the correct outcome, not a bug. +struct NodeListView: View { + @Environment(WatchSessionClient.self) private var client + + private var nodes: [WatchHeardNode] { client.snapshot?.geo.heard ?? [] } + + var body: some View { + Group { + if nodes.isEmpty { + emptyState + } else { + List { + ForEach(nodes) { node in + NavigationLink { + NodeDetailView(node: node) + } label: { + NodeRow(node: node) + } + } + } + .listStyle(.carousel) + } + } + .opacity(client.isStale ? 0.5 : 1.0) + } + + private var emptyState: some View { + VStack(spacing: 6) { + Image(systemName: "antenna.radiowaves.left.and.right.slash") + .font(.title3) + .foregroundStyle(.secondary) + Text("Nothing heard yet") + .font(.footnote) + .foregroundStyle(.secondary) + } + } +} + +/// One repeater. +/// +/// Two lines: identity and signal on top, context underneath. The SNR dot +/// repeats the colour information as a number so the row still reads under a +/// colour-vision palette or in bright sun. +struct NodeRow: View { + let node: WatchHeardNode + + var body: some View { + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 5) { + // Dot carries the ping type, matching the map overlay. + Circle() + .fill(Color(node.typeColor)) + .frame(width: 7, height: 7) + // The hex path hash is the identity — it is always available and + // always unambiguous, which a resolved name is not. + Text(node.id) + .font(.body.monospaced()) + Spacer(minLength: 4) + if let snr = node.snr { + Text(snr, format: .number.precision(.fractionLength(1))) + .font(.body.monospacedDigit()) + .foregroundStyle(node.snrColor.map(Color.init) ?? .primary) + } + } + + if let subtitle { + Text(subtitle) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(.vertical, 1) + } + + /// Name and distance are both optional: a short path hash may match several + /// repeaters, and a matched repeater may not have published a location. + private var subtitle: String? { + var parts: [String] = [] + if let name = node.name { parts.append(name) } + if let distance = node.distanceLabel { parts.append(distance) } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } +} + +struct NodeDetailView: View { + let node: WatchHeardNode + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 5) { + Circle().fill(Color(node.typeColor)).frame(width: 9, height: 9) + Text(node.id) + .font(.headline.monospaced()) + } + + if let name = node.name { + Text(name) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } else { + Text("Name unresolved — short path hash") + .font(.caption2) + .foregroundStyle(.secondary) + } + + if let snr = node.snr { + detail("SNR", snr.formatted(.number.precision(.fractionLength(1))) + " dB") + } + if let distance = node.distanceLabel { + detail("Distance", distance) + } + detail("Heard", node.heardAt.formatted(date: .omitted, time: .shortened)) + } + .padding(.horizontal, 4) + } + } + + private func detail(_ label: String, _ value: String) -> some View { + HStack(alignment: .firstTextBaseline) { + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + Spacer(minLength: 6) + Text(value) + .font(.caption) + .multilineTextAlignment(.trailing) + } + } +} + +extension WatchHeardNode { + var heardAt: Date { Date(timeIntervalSince1970: atMs / 1000) } + + var distanceLabel: String? { + guard let distanceM else { return nil } + if distanceM < 1000 { + return "\(Int(distanceM.rounded())) m" + } + return (distanceM / 1000).formatted(.number.precision(.fractionLength(1))) + " km" + } +} diff --git a/ios/MeshMapperWatch/SampleSnapshot.swift b/ios/MeshMapperWatch/SampleSnapshot.swift new file mode 100644 index 00000000..485416d7 --- /dev/null +++ b/ios/MeshMapperWatch/SampleSnapshot.swift @@ -0,0 +1,283 @@ +#if DEBUG +import Foundation + +/// Synthetic state for design work and headless verification. +/// +/// The simulator has no Bluetooth, so a paired simulator pair can never +/// produce pings or repeaters — the map would always be empty there. Enable +/// with a launch argument: +/// +/// xcrun simctl launch net.meshmapper.app.watchkitapp -MeshMapperSampleData YES +/// +/// Other DEBUG launch arguments used by the capture harness: +/// +/// - `-MeshMapperSamplePhase listen|wait|lapsed` exercises phase timing. +/// - `-MeshMapperSampleControls active|idle|blocked|cooldown|passiveOnly|txActive` +/// exercises every toolbar slot state. +/// - `-MeshMapperLongIds YES` exercises six-character path hashes. +/// - `-MeshMapperShowNodeSheet YES` opens the heard-node sheet. +/// - `-MeshMapperInitialPage ` opens a specific vertical page. +/// - `-MeshMapperForceDimmed YES` renders the reduced-luminance readout. +/// - `-MeshMapperForceRefusal ` presents the failure banner; capture +/// within six seconds because it deliberately uses the production expiry. +/// - `-MeshMapperSampleWalk YES` advances the sample fix by 15 m every 11 s, +/// which is what the phone's movement gate and walking pace actually deliver. +/// Without it the fix never moves and the follow camera has nothing to +/// animate, so this is the only way to review that motion off a wrist. +/// Pass a number instead of `YES` to set the interval — the simulator stops +/// redrawing a few seconds after launch, so a capture run needs steps far +/// closer together than a walk delivers. See `walkStepInterval`. +/// - `-MeshMapperAutoPageTo ` switches pages five seconds after launch. +/// The simulator cannot swipe, and page transitions are a real defect +/// surface: a sheet raised from the map once survived onto the next page, +/// blurring it and swallowing every swipe, which reads exactly like a crash. +/// - `-MeshMapperTimeSwitchToMap YES` flips the main page to the map five +/// seconds after launch and logs `[switch] requesting map at `, so the +/// MapKit rebuild a wrist raise pays can be timed against screenshot mtimes. +/// Always pass `-layout.mainPageContent readout` with it: starting on the map +/// makes the flip a no-op that still logs, which looks like a fast result. +/// It also logs `[span] rendered … requested … confirmed …` on every camera +/// callback, which is how to check on hardware whether a rebuilt map ever +/// reports a region that is not the one we asked for. In the simulator it +/// never does — three teardown cycles, 0.0% drift every time. +/// +/// Listening and active remain the defaults so existing capture commands keep +/// their behaviour. +/// +/// Every affordance is DEBUG-only and requires its explicit argument, so none +/// can leak into a shipping build or mask a real transport failure. +enum SampleSnapshot { + static var isEnabled: Bool { + UserDefaults.standard.bool(forKey: "MeshMapperSampleData") + } + + /// Downtown Seattle, roughly — somewhere with enough spread to see marker + /// density and line geometry at a realistic zoom. + private static let originLat = 47.6062 + private static let originLon = -122.3321 + + /// Metres travelled per emitted step, matching the phone's movement gate. + /// + /// `WatchWire.minMoveMeters` is 15, so a walking wearer's fix advances in 15 m + /// jumps rather than continuously. Reproducing the *step size* is the whole + /// point of this harness: a smaller one would make the follow animation look + /// fine for reasons the real wire does not provide. + static let walkStepMeters: Double = 15 + + /// Seconds between steps. Defaults to 15 m at an ordinary walking pace + /// (~1.4 m/s); `-MeshMapperSampleWalk ` overrides it. + /// + /// **The override exists because the simulator's display stops updating a few + /// seconds after launch** — measured at ~4.5 s, after which every screenshot + /// is the same frame and even a `Text(timerInterval:)` sits frozen. That is + /// the screen sleeping, not the app dimming: the readout never replaces the + /// map, so the dim hold plainly did not run. A capture run therefore has only + /// a few seconds of live rendering, and a real 11 s cadence puts zero steps + /// inside it. Step size is what the follow animation actually has to smooth, + /// so shortening the interval costs the review nothing. + static var walkStepInterval: TimeInterval { + let override = UserDefaults.standard.double(forKey: "MeshMapperSampleWalk") + return override > 0 ? override : 11 + } + + /// Whether to walk at all. The interval override doubles as the switch, so + /// `YES` reads as "walk at the real cadence" and a number as "walk this fast". + static var isWalking: Bool { + UserDefaults.standard.double(forKey: "MeshMapperSampleWalk") > 0 + || UserDefaults.standard.bool(forKey: "MeshMapperSampleWalk") + } + + /// Course held while walking, in degrees clockwise from north. Matches the + /// stationary sample's `headingDeg` so the puck glyph does not swing when the + /// harness is switched on. + private static let walkBearingDeg: Double = 72 + + static func make(stepsWalked: Int = 0) -> WatchSnapshot { + let now = Date().timeIntervalSince1970 * 1000 + + // Flat-earth offset, which at these distances is exact to well under a + // metre and keeps the harness free of a projection. + let travelled = Double(stepsWalked) * walkStepMeters + let bearing = walkBearingDeg * .pi / 180 + let walkLat = travelled * cos(bearing) / 111_320 + let walkLon = travelled * sin(bearing) + / (111_320 * cos((originLat + 0.006) * .pi / 180)) + + let samplePhase: (name: String, title: String, endsAtMs: Double, durationMs: Int) + switch UserDefaults.standard.string(forKey: "MeshMapperSamplePhase") { + case "wait": + samplePhase = ("waiting", "Next ping", now + 25_000, 30_000) + case "lapsed": + samplePhase = ("listening", "Listening…", now - 5_000, 60_000) + default: + samplePhase = ("listening", "Listening…", now + 42_000, 60_000) + } + + let sampleControlState = UserDefaults.standard.string( + forKey: "MeshMapperSampleControls" + ) + let sampleControls: WatchControls + switch sampleControlState { + case "idle": + sampleControls = WatchControls( + canStartStop: true, + canManualPing: false, + isSessionActive: false, + manualPingApplicable: true, + manualCooldownEndsAtMs: nil, + blockedReason: nil + ) + case "blocked": + sampleControls = WatchControls( + canStartStop: false, + canManualPing: false, + isSessionActive: false, + manualPingApplicable: false, + manualCooldownEndsAtMs: nil, + blockedReason: "This zone is currently passive-only" + ) + case "cooldown": + // The one unavailability the phone reports with no `blockedReason`. + sampleControls = WatchControls( + canStartStop: true, + canManualPing: false, + isSessionActive: true, + manualPingApplicable: true, + manualCooldownEndsAtMs: now + 12_000, + blockedReason: nil + ) + case "passiveOnly": + sampleControls = WatchControls( + canStartStop: true, + canManualPing: false, + isSessionActive: true, + manualPingApplicable: false, + manualCooldownEndsAtMs: nil, + blockedReason: "Passive Only" + ) + case "txActive": + sampleControls = WatchControls( + canStartStop: true, + canManualPing: false, + isSessionActive: true, + manualPingApplicable: false, + manualCooldownEndsAtMs: nil, + blockedReason: nil + ) + default: + sampleControls = WatchControls( + canStartStop: true, + canManualPing: true, + isSessionActive: true, + manualPingApplicable: true, + manualCooldownEndsAtMs: nil, + blockedReason: nil + ) + } + + let green = WatchColor(r: 0.30, g: 0.69, b: 0.31) + let red = WatchColor(r: 0.96, g: 0.26, b: 0.21) + let purple = WatchColor(r: 0.49, g: 0.33, b: 0.78) + let pink = WatchColor(r: 0.84, g: 0.20, b: 0.52) + let orange = WatchColor(r: 0.99, g: 0.49, b: 0.08) + + // A drive west-to-east with a mix of answered and unanswered pings. + let pings: [WatchPing] = (0..<40).map { i in + let answered = i % 3 != 0 + return WatchPing( + id: "p\(i)", + lat: originLat + Double(i) * 0.00035 + (i % 2 == 0 ? 0.0002 : -0.0002), + lon: originLon + Double(i) * 0.00085, + kind: i % 5 == 0 ? "rx" : "tx", + color: i % 5 == 0 ? purple : (answered ? green : red), + atMs: now - Double(40 - i) * 20_000 + ) + } + + let repeaters: [WatchRepeater] = [ + ("4E", "Capitol Hill", 0.010, 0.004, pink, true), + ("77", "Queen Anne", -0.006, 0.012, pink, true), + ("A2", "Beacon Hill", -0.012, -0.008, orange, false), + ("B9", "Magnolia", 0.004, -0.015, pink, false), + ].map { id, name, dLat, dLon, color, heard in + WatchRepeater( + id: id, + hexId: id, + name: name, + lat: originLat + dLat, + lon: originLon + dLon, + color: color, + heardThisCycle: heard + ) + } + + // Mirrors the phone's "Top Heard": three rows from the latest ping, then + // the RX slot in purple. Names are deliberately mixed — a 4-char hash + // resolves, a 2-char one often cannot. + let teal = WatchColor(r: 0.32, g: 0.83, b: 0.91) + // Pass -MeshMapperLongIds YES to render the 3-byte-zone worst case: six + // hex characters per row, which is what the box has to survive on 40 mm. + let long = UserDefaults.standard.bool(forKey: "MeshMapperLongIds") + let heard: [WatchHeardNode] = [ + (long ? "4E5D82" : "4E5D", "Capitol Hill", 8.5, 1420.0, green), + (long ? "77A1B0" : "77A1", nil, 2.25, 2310.0, teal), + (long ? "A2FF31" : "A2", nil, -14.5, nil, green), + (long ? "B914C2" : "B914", "Magnolia", 5.75, 2870.0, purple), + ].map { id, name, snr, distance, typeColor in + WatchHeardNode( + id: id, + name: name, + snr: snr, + atMs: now - 30_000, + distanceM: distance, + snrColor: snr > 5 + ? WatchColor(r: 0.30, g: 0.69, b: 0.31) + : (snr > -1 + ? WatchColor(r: 1.0, g: 0.60, b: 0.0) + : WatchColor(r: 0.96, g: 0.26, b: 0.21)), + typeColor: typeColor + ) + } + + return WatchSnapshot( + wireVersion: MeshMapperWatchWire.version, + sessionId: "sample", + // Ping can own the active-session slot only during Passive monitoring; + // Active and Hybrid are TX modes and keep Stop reachable instead. + mode: sampleControlState == "txActive" ? "Hybrid" : "Passive", + phase: samplePhase.name, + phaseTitle: samplePhase.title, + phaseDetail: "Waiting for echoes", + phaseEndsAtMs: samplePhase.endsAtMs, + phaseDurationMs: samplePhase.durationMs, + isConnected: true, + zoneCode: "SEA", + txCount: 27, + rxCount: 14, + discoveryCount: 6, + traceCount: 0, + queueSize: 2, + pingColor: green, + availableStartModes: sampleControlState == "passiveOnly" + ? ["passive"] + : ["passive", "hybrid"], + geo: WatchGeo( + you: WatchPosition( + lat: originLat + 0.006 + walkLat, + lon: originLon + 0.014 + walkLon, + headingDeg: walkBearingDeg, + accuracyM: 8, + fixedAtMs: now + ), + pings: pings, + repeaters: repeaters, + heard: heard, + linkedRepeaterIds: ["4E", "77"] + ), + controls: sampleControls, + cue: nil, + updatedAtMs: now + ) + } +} +#endif diff --git a/ios/MeshMapperWatch/SettingsPage.swift b/ios/MeshMapperWatch/SettingsPage.swift new file mode 100644 index 00000000..d8497476 --- /dev/null +++ b/ios/MeshMapperWatch/SettingsPage.swift @@ -0,0 +1,90 @@ +import SwiftUI + +/// Wrist-side layout and map preferences. +/// +/// Deliberately small. Anything about the session itself belongs on the phone, +/// which owns the radio and the guards. +struct SettingsPage: View { + @Environment(WatchSettings.self) private var settings + @Environment(WatchSessionClient.self) private var client + + private var availableStartModes: [WatchSettings.DefaultStartMode] { + let available = client.snapshot?.availableStartModes ?? ["passive"] + return WatchSettings.DefaultStartMode.allCases.filter { + $0 == .passive || available.contains($0.rawValue) + } + } + + private var effectiveStartMode: WatchSettings.DefaultStartMode { + settings.effectiveStartMode( + availableStartModes: client.snapshot?.availableStartModes + ) + } + + var body: some View { + @Bindable var settings = settings + + List { + Section("Display") { + Picker("Main page", selection: $settings.mainPageContent) { + ForEach(WatchSettings.MainPageContent.allCases) { content in + Text(content.label).tag(content) + } + } + Picker("Node list", selection: $settings.nodeListPlacement) { + ForEach(WatchSettings.NodeListPlacement.allCases) { placement in + Text(placement.label).tag(placement) + } + } + } + + Section("Map") { + Toggle("Follow position", isOn: $settings.follow) + Toggle("Lines to repeaters", isOn: $settings.showLinks) + } + + Section("Controls") { + Picker( + "Default start mode", + selection: Binding( + get: { effectiveStartMode }, + set: { settings.defaultStartMode = $0 } + ) + ) { + ForEach(availableStartModes) { mode in + Text(mode.label).tag(mode) + } + } + + if settings.defaultStartMode != effectiveStartMode { + Text("Hybrid unavailable here; Start uses Passive") + .foregroundStyle(WatchPalette.tertiary) + } + + Toggle( + "When available, show ping option", + isOn: $settings.showPingWhenAvailable + ) + + // Sits with Controls because the only cue the phone sends is the + // failure of a control the wearer used. It is the wearer's own wrist, + // so the switch stays on the wrist: silencing it must not depend on + // the phone link that just failed. + Toggle("Haptic feedback", isOn: $settings.haptics) + } + + #if DEBUG + // Not a preference — an A/B switch for an Instruments trace, so it never + // reaches Release. Takes effect from the next phase rather than mid-drain. + Section("Instruments") { + Toggle("Freeze timer bar", isOn: $settings.freezesTimerBar) + // Persisted, unlike the launch argument this replaced — a wrist-down + // test invites watchOS to relaunch the app, which silently emptied + // `NSArgumentDomain` and produced an empty log with no clue why. + Toggle("Log wake timing", isOn: $settings.logsWakeTiming) + } + #endif + } + .font(.caption) + } +} diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift new file mode 100644 index 00000000..77b4a71b --- /dev/null +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -0,0 +1,733 @@ +import Foundation +import SwiftUI +import WatchConnectivity +import WatchKit + +/// Receives snapshots from the iPhone and sends intents back. +/// +/// The watch never decides anything: it renders what the phone sent and asks +/// for what the wearer tapped. The phone owns the BLE link, the GPS fix, and +/// every guard around transmitting. +/// +/// **Main-actor isolated, because everything it stores is rendered.** +/// WatchConnectivity documents that "the methods of this protocol are called on +/// a background thread of your app", and every stored property here is read by +/// SwiftUI through `@Observable`. The delegate conformance below is therefore +/// `nonisolated` and hops explicitly; see the note on the extension for what +/// deliberately stays off the main actor. +@Observable +@MainActor +final class WatchSessionClient: NSObject { + /// Read for exactly one decision: whether a cue may play a haptic. + /// + /// Held rather than resolved from `UserDefaults` here so the preference has + /// one owner. Two surfaces resolving the same preference independently is + /// the failure `effectiveStartMode` exists to prevent, and a silenced watch + /// that still buzzes would be the same class of bug. + @ObservationIgnored private let settings: WatchSettings + + init(settings: WatchSettings) { + self.settings = settings + super.init() + } + + /// Latest state from the phone, or nil before the first delivery. + private(set) var snapshot: WatchSnapshot? + + /// When the last snapshot arrived — drives the stale badge. + private(set) var receivedAt: Date? + + /// Stored rather than derived from `Date()`: time passing does not invalidate + /// a SwiftUI observation by itself, so the boundary has to become an event. + private(set) var isStale = true + private var staleBoundaryTask: Task? + + /// The phone's explanation for a refused admission or a later failed action. + /// Both belong to one short-lived presentation path on the controls page. + private(set) var lastRefusal: String? + + /// Explicit attribution lets Controls place feedback beside the action it + /// explains without guessing that an unrelated phone event belongs to the + /// last thing tapped. It never affects admission, transport, or the command. + private(set) var lastRefusalCommand: WatchCommand.Kind? + + /// A refusal explains one completed tap, not the current transport state. + /// Restarting its lifetime on replacement prevents an older expiry from + /// erasing newer feedback that happens to arrive near the same moment. + private var refusalExpiryTask: Task? + + /// Immediate messages and application context can deliver the same cue in + /// either order. IDs make that transport redelivery one visible event. + private var presentedCueIDs = Set() + private var presentedCueIDOrder = [String]() + + /// Wearer-initiated command awaiting evidence of phone-side progress. + /// Automatic refreshes stay out because they have no corresponding wrist + /// action, and queued delivery has no acknowledgement to wait for. + private(set) var pendingCommand: WatchCommand.Kind? + private var pendingCommandTimeoutTask: Task? + + /// Set when a payload arrives from a wire version this build predates. + private(set) var versionMismatch = false + + /// Phone clock minus watch clock, learned from a live delivery. + /// + /// The two devices do not share a clock, and nothing guarantees they agree. + /// Every phone-stamped time this class reads — how old a snapshot is, how old + /// a cue is — and every watch-stamped time the phone reads back was compared + /// across that gap with five seconds of slack, so a larger disagreement did + /// not degrade: it refused every command and marked every payload stale, for + /// as long as the skew lasted. + /// + /// A live `sendMessage` crosses in milliseconds, so the phone's `updatedAt` + /// on one of those is effectively a reading of the phone's clock taken now. + /// The difference from the local clock is the offset, and correcting by it + /// turns an unshared clock back into a measurement problem with a small + /// residual — which is what the five seconds were always meant to cover. + /// + /// Nil until a live delivery has been seen, where every comparison falls back + /// to the raw timestamps exactly as before. + private(set) var clockOffset: TimeInterval? + + /// Whether the currently rendered surface needs map-only geography. + /// Launches begin true: an unnecessary full payload is preferable to a map + /// that opens blank before this process has described its current surface. + private var mapGeoNeeded = true + private var lastSentMapGeoNeeded: Bool? + private var mapGeoSuppressionTask: Task? + private var mapGeoRenewalTask: Task? + private var lastMapGeoRecoveryRequestAt: Date? + + private var session: WCSession? { + WCSession.isSupported() ? WCSession.default : nil + } + + /// Mirrors `WCSession.isReachable`, stored rather than computed. + /// + /// Reading `WCSession.default.isReachable` inside a computed property takes + /// no observation dependency, so a view rendering it was never invalidated + /// when reachability changed — it showed whatever happened to be true at the + /// last unrelated render. `sessionReachabilityDidChange` is the callback that + /// turns the change into an event, exactly as its documentation intends. + private(set) var isReachable = false + + /// The timing rules this client applies live in `WatchWireRules`, which is + /// Foundation-only and therefore reachable from `swift test`. Kept as an + /// alias because the surrounding view code reads better for it. + static let staleAfter = WatchWireRules.staleAfter + private static let mapGeoSuppressionDelay: TimeInterval = 15 + private static let mapGeoRenewalInterval: TimeInterval = 5 * 60 + private static let mapGeoRecoveryThrottle: TimeInterval = 3 + + /// Bring the session up and pull a current snapshot. + /// + /// Activation is asynchronous, so a refresh requested before it completes is + /// deferred to the activation callback rather than failing as "unreachable". + func refresh() { + #if DEBUG + if SampleSnapshot.isEnabled { + snapshot = SampleSnapshot.make() + markSnapshotReceived() + startSampleWalkIfRequested() + return + } + #endif + + guard let session else { return } + session.delegate = self + + if session.activationState == .activated { + // An already-activated session fires no activation callback, so this is + // the only chance to seed reachability before the first render. + noteReachability(session.isReachable) + ingest(context: session.receivedApplicationContext) + requestFullSnapshot() + if !mapGeoNeeded { scheduleMapGeoSuppression() } + return + } + + pendingRefresh = true + session.activate() + } + + /// Reconcile after the scene becomes active again. + /// + /// `onAppear` is not a resume callback — it fires once — and watchOS suspends + /// this app for essentially the whole wrist-down interval, measured here at + /// 7.42 s of suspension against 8.20 s of wrist-down. So without this a + /// wearer could raise their wrist onto state the UI itself calls stale and + /// have nothing ask the phone to prove otherwise. + /// + /// Deliberately not `refresh()`. That always requests, which would put a + /// WatchConnectivity round trip behind every glance — the opposite of what + /// this transport is built for. Ingesting the retained context is free, so + /// do it always; spend the radio only when what we hold is stale or missing, + /// which is exactly when a request can change what the wearer sees. + func resume() { + #if DEBUG + if SampleSnapshot.isEnabled { return } + #endif + + guard let session else { return } + session.delegate = self + + guard session.activationState == .activated else { + pendingRefresh = true + session.activate() + return + } + + // A reachability change during the suspension has no delegate callback to + // deliver, so re-read it alongside the retained context. + noteReachability(session.isReachable) + + // Applied synchronously, unlike every other ingest. The queued variant + // lands after this method returns, so the decision below was made against + // pre-resume state — and the 90 s staleness flip inside `apply` is queued + // the same way. Whichever way main-actor ordering happened to fall, one of + // the two failures this method exists to prevent came back: a `forceRefresh` + // full snapshot on every glance past the boundary despite holding a + // seconds-old context, or a wearer left looking at stale UI. + ingestNow(context: session.receivedApplicationContext) + + // Read after ingesting: a context retained while the wrist was down may + // have just answered the question, and then the radio is not needed. + let needsPhone = snapshot == nil || isStale + #if DEBUG + // The whole claim of this method in one line per glance. A wrist-raise run + // should show `resume-local` for fresh glances and exactly one + // `resume-request` for the first glance past the stale boundary. + WakeLog.note(needsPhone ? "resume-request" : "resume-local") + #endif + if needsPhone { requestFullSnapshot() } + + if !mapGeoNeeded { scheduleMapGeoSuppression() } + } + + private var pendingRefresh = false + + // MARK: - Commands + + /// - Parameter silent: suppress the refusal banner. Used for the automatic + /// refresh, which the wearer never asked for and shouldn't see fail. + /// Queues without pre-checking `isReachable`. + /// + /// Wrist controls normally run while the phone app is not foregrounded, + /// where `sendMessage` can execute the command yet fail its reply as + /// undeliverable. One queued path avoids both that false failure and the + /// duplicate-transmit risk of retrying an ambiguously delivered message. + func send( + _ kind: WatchCommand.Kind, + mode: String? = nil, + mapGeoNeeded: Bool? = nil, + forceRefresh: Bool = false, + silent: Bool = false + ) { + guard let session, session.activationState == .activated else { + if !silent { + setLastRefusal("Not connected to iPhone", from: kind) + } + return + } + + let command = WatchCommand( + kind: kind, + mode: mode, + mapGeoNeeded: mapGeoNeeded, + forceRefresh: forceRefresh ? true : nil, + id: UUID().uuidString, + issuedAtMs: Date().timeIntervalSince1970 * 1000, + // Sent alongside the watch-clock stamp rather than folded into it: the + // phone needs this to measure age, but uses the raw stamp to order + // map-geo claims, and rewriting that key would make it jump backwards + // the first time an offset is learned. + clockOffsetMs: clockOffset.map { $0 * 1000 }, + // Named here rather than by each caller, so no Stop button can forget + // it. This is the session the wearer was actually looking at when they + // tapped; the phone refuses the stop if it has since moved on. + sessionId: kind == .stopSession ? snapshot?.sessionId : nil + ) + guard let data = try? MeshMapperWatchWire.encoder.encode(command), + let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + if !silent { + setLastRefusal("Could not encode command", from: kind) + } + return + } + + if !silent { + setLastRefusal(nil, from: kind) + beginPending(kind) + } + + session.transferUserInfo([MeshMapperWatchWire.commandKey: dict]) + } + + /// Report whether the current surface needs its expensive marker payload. + /// Returning to the map is immediate; suppression waits out short wrist-down + /// transitions so ordinary glances do not enqueue a false/true pair. + func setMapGeoNeeded(_ needed: Bool) { + mapGeoNeeded = needed + mapGeoSuppressionTask?.cancel() + mapGeoSuppressionTask = nil + + if needed { + mapGeoRenewalTask?.cancel() + mapGeoRenewalTask = nil + sendMapGeoPreference(true) + } else { + scheduleMapGeoSuppression() + } + } + + private func scheduleMapGeoSuppression() { + mapGeoSuppressionTask?.cancel() + mapGeoSuppressionTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(Self.mapGeoSuppressionDelay)) + guard !Task.isCancelled, let self, !self.mapGeoNeeded else { return } + self.mapGeoSuppressionTask = nil + self.sendMapGeoPreference(false) + self.scheduleMapGeoRenewal() + } + } + + private func scheduleMapGeoRenewal() { + mapGeoRenewalTask?.cancel() + mapGeoRenewalTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(Self.mapGeoRenewalInterval)) + guard !Task.isCancelled, let self, !self.mapGeoNeeded else { return } + self.mapGeoRenewalTask = nil + // The phone treats suppression as a lease. Renewal keeps a long + // Always-On session cheap; if this task stops, the lease expires back + // to full geography rather than leaving a future map blank. + self.sendMapGeoPreference(false, force: true) + self.scheduleMapGeoRenewal() + } + } + + /// - Parameter force: resend even when the phone was already told this value, + /// which is what renews the lease. + /// - Parameter refresh: additionally demand a snapshot back. Lease traffic + /// leaves this false so an unchanged session stays silent. + private func sendMapGeoPreference( + _ needed: Bool, + force: Bool = false, + refresh: Bool = false + ) { + guard force || lastSentMapGeoNeeded != needed else { return } + guard let session, session.activationState == .activated else { return } + lastSentMapGeoNeeded = needed + send( + .requestSnapshot, + mapGeoNeeded: needed, + forceRefresh: refresh, + silent: true + ) + } + + private func requestFullSnapshot() { + // Activation always starts from the safe assumption even if a retained + // application context says the previous process had suppressed its map. + // + // This is the one caller that genuinely needs state back: whatever the + // watch holds came from a retained context of unknown age, so an unchanged + // session must still answer rather than dedupe into silence. + sendMapGeoPreference(true, force: true, refresh: true) + } + + private func beginPending(_ kind: WatchCommand.Kind) { + pendingCommandTimeoutTask?.cancel() + pendingCommand = kind + pendingCommandTimeoutTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(10)) + guard !Task.isCancelled, self?.pendingCommand == kind else { return } + self?.pendingCommand = nil + self?.pendingCommandTimeoutTask = nil + } + } + + private func clearPendingCommand() { + pendingCommandTimeoutTask?.cancel() + pendingCommandTimeoutTask = nil + pendingCommand = nil + } + + #if DEBUG + /// Seed a refusal so the failure banner can be captured headlessly. + /// + /// The banner is otherwise unreachable in the simulator: producing one needs + /// a command to be refused, and there is no way to tap a control there. It + /// expires on the usual six-second schedule, so a capture must be taken + /// inside that window — a screenshot at eight seconds shows an empty screen + /// and reads exactly like a broken banner. + func debugForceRefusal(_ message: String) { + setLastRefusal(message, from: .startSession) + } + #endif + + private func setLastRefusal( + _ refusal: String?, + from command: WatchCommand.Kind? + ) { + refusalExpiryTask?.cancel() + refusalExpiryTask = nil + lastRefusal = refusal + lastRefusalCommand = refusal == nil ? nil : command + + guard refusal != nil else { return } + refusalExpiryTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(6)) + guard !Task.isCancelled else { return } + self?.lastRefusal = nil + self?.lastRefusalCommand = nil + self?.refusalExpiryTask = nil + } + } + + /// - Parameter producedAt: when the phone built the payload. Absent for the + /// debug sample, which is generated on the wrist. + private func markSnapshotReceived(at arrival: Date = Date(), producedAt: Date? = nil) { + staleBoundaryTask?.cancel() + staleBoundaryTask = nil + + let (origin, remaining) = WatchWireRules.reception( + arrival: arrival, + producedAt: producedAt, + clockOffset: clockOffset + ) + receivedAt = origin + + guard remaining > 0 else { + isStale = true + return + } + isStale = false + + // One task per delivery makes the 90-second boundary observable without a + // polling timer. A newer snapshot cancels this task and owns the next one. + staleBoundaryTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(remaining)) + guard !Task.isCancelled, self?.receivedAt == origin else { return } + self?.isStale = true + self?.staleBoundaryTask = nil + } + } + + + /// The main-actor half of the activation callback. + private func completeActivation() { + guard pendingRefresh else { return } + pendingRefresh = false + requestFullSnapshot() + if !mapGeoNeeded { scheduleMapGeoSuppression() } + } + + private func noteReachability(_ reachable: Bool) { + guard isReachable != reachable else { return } + isReachable = reachable + } + + #if DEBUG + /// Advance the sample fix so the follow camera has something to track. + /// + /// The stationary sample snapshot can show that the map *renders*, never that + /// it moves well — and motion is the one thing the follow animation exists + /// for. This walks the fix at the real wire's step size and cadence rather + /// than smoothly, because a continuous feed would hide exactly the jump being + /// smoothed. + /// + /// Idempotent: `refresh()` runs on every scene activation and must not leave + /// a second walker behind, which would double the pace. + private func startSampleWalkIfRequested() { + guard sampleWalkTask == nil, SampleSnapshot.isWalking else { return } + + let interval = SampleSnapshot.walkStepInterval + sampleWalkTask = Task { @MainActor [weak self] in + var steps = 0 + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(interval)) + guard !Task.isCancelled, let self else { return } + steps += 1 + self.snapshot = SampleSnapshot.make(stepsWalked: steps) + self.markSnapshotReceived() + } + } + } + + /// Not observed: nothing renders the walker, and letting it invalidate views + /// would make the harness a source of the redraws it is meant to measure. + @ObservationIgnored private var sampleWalkTask: Task? + #endif + + /// Let the wearer feel a cue the phone raised. + /// + /// **This only fires while watchOS is actually running this app.** The system + /// ignores `play(_:)` from a suspended or background app, so a failure that + /// lands during a long wrist-down is still seen rather than felt. The case it + /// answers is the real one: a wearer taps Ping or Start, gets an accepted + /// ack, glances away, and the command fails a moment later with the app + /// still frontmost. Before this the only report was a banner on a screen the + /// wearer had stopped reading. + /// + /// Unknown kinds still play. The wire calls this "a one-shot event the watch + /// should feel", so an older watch meeting a newer phone should err toward + /// the generic notification rather than toward silence. + private func play(_ cue: WatchHapticCue) { + guard settings.haptics else { return } + let type: WKHapticType + switch cue.kind { + case "success": type = .success + case "failure": type = .failure + default: type = .notification + } + WKInterfaceDevice.current().play(type) + } + + // MARK: - Ingest + + /// What a delivered payload turned out to be. + private enum Ingested { + case snapshot(WatchSnapshot) + case unsupportedVersion + case undecodable + } + + /// Just the version, so it can be read from a payload the full model cannot + /// decode. + /// + /// `WatchSnapshot` decodes its required fields with a throwing decoder, so a + /// future wire that removes or renames one — a documented reason to bump + /// `MeshMapperWatchWire.version` — fails to decode before the version is ever + /// examined. That is precisely the case the refusal exists for: without this + /// the watch would silently drop every payload and go permanently stale + /// instead of telling the wearer to update the iPhone app. + private struct WireVersionProbe: Decodable { + let wireVersion: Int + } + + private nonisolated static func decode(_ data: Data) -> Ingested { + let decoder = MeshMapperWatchWire.decoder + guard let probe = try? decoder.decode(WireVersionProbe.self, from: data) else { + return .undecodable + } + // Refuse rather than render a payload whose fields may have changed + // meaning — a wrong reading on the wrist is worse than a blank one. + guard probe.wireVersion == MeshMapperWatchWire.version else { + return .unsupportedVersion + } + guard let decoded = try? decoder.decode(WatchSnapshot.self, from: data) else { + return .undecodable + } + return .snapshot(decoded) + } + + /// `nonisolated` on purpose: decoding is the expensive part, it needs no + /// isolation, and WatchConnectivity already hands it to us on a background + /// thread. Only `apply` crosses onto the main actor. + private nonisolated func ingest(context: [String: Any]) { + guard let data = context[MeshMapperWatchWire.payloadKey] as? Data else { return } + ingest(data: data) + } + + /// - Parameter live: whether this arrived by `sendMessage`, whose transit is + /// milliseconds. Only those can date the phone's clock; an application + /// context may have been retained for hours before this process read it. + private nonisolated func ingest(data: Data, live: Bool = false) { + switch Self.decode(data) { + case .undecodable: + return + case .unsupportedVersion: + Task { @MainActor [weak self] in self?.versionMismatch = true } + case .snapshot(let decoded): + Task { @MainActor [weak self] in self?.apply(decoded, live: live) } + } + } + + /// Ingest without the main-actor hop, for a caller that has to see the result + /// before it can decide anything. + /// + /// The asynchronous path above is the right default — a 12 kB parse does not + /// belong on the wrist's main thread on every snapshot. This one runs once + /// per wrist raise, where the alternative is deciding whether to spend the + /// radio against state the queued apply has not delivered yet. + private func ingestNow(context: [String: Any]) { + guard let data = context[MeshMapperWatchWire.payloadKey] as? Data else { return } + switch Self.decode(data) { + case .undecodable: + return + case .unsupportedVersion: + versionMismatch = true + case .snapshot(let decoded): + apply(decoded) + } + } + + /// Whether `decoded` supersedes what is already rendered. + /// + /// **The two delivery paths are not ordered with respect to each other.** An + /// urgent update goes out as `sendMessage` *and* as + /// `updateApplicationContext`, and only the first is fast. `sendMessage` + /// does not populate `receivedApplicationContext`, so the retained context + /// keeps whatever last arrived down the slow path — which can be a payload + /// this client already superseded live. + /// + /// That was enough to strand a wearer on stale state with nothing asking for + /// better: phone sends P2 both ways, the watch applies P2 from the message, + /// the wrist drops and rises before context P2 lands, `resume` ingests the + /// retained context and applies P1 over it. P1 is under 90 s old so `resume` + /// sees no reason to spend the radio, and the phone's own caches already + /// equal P2, so nothing further is sent. The wearer reads P1 until something + /// unrelated moves. + /// + /// Both stamps come from the same phone clock, so they are compared raw — + /// `inLocalTime` exists for phone-instant against watch-now, which this is + /// not. Equal stamps are accepted: two payloads built in the same + /// millisecond are interchangeable, and a forced refresh always restamps + /// `updatedAt`, so it can never be mistaken for a replay of itself. + /// + /// **A stale surface accepts anything.** Ordering is worth enforcing only + /// while what is held is still worth protecting. Bounding it this way means + /// the worst case of a phone clock stepping backwards is 90 seconds of + /// refusal — after which the boundary flips, the next payload lands, and the + /// watch recovers on its own. A bare timestamp comparison would refuse for + /// the life of the process. + /// + /// Deliberately no wire `seq`. It would need a phone-process identity beside + /// it — a counter that restarts at zero is indistinguishable from an ancient + /// one — to buy behaviour this already has without a version conversation. + /// + /// A rejected payload does not clear a pending command, and does not need + /// to: for one to be rejected, a newer payload must already have been + /// applied, and that one cleared it. + private func isNewerThanCurrent(_ decoded: WatchSnapshot) -> Bool { + WatchWireRules.supersedes(decoded, current: snapshot, isStale: isStale) + } + + private func apply(_ decoded: WatchSnapshot, live: Bool = false) { + let arrival = Date() + + guard isNewerThanCurrent(decoded) else { return } + + // Learned before anything below reads a phone-stamped time. + // + // Reached only by a payload that won the ordering check above, which also + // keeps a *delayed* live message from teaching a wrong offset: its transit + // is the measurement, so one that arrives out of order has already failed + // the assumption the measurement rests on. + if live { + clockOffset = decoded.updatedAt.timeIntervalSince(arrival) + } + + versionMismatch = false + snapshot = decoded + markSnapshotReceived(at: arrival, producedAt: decoded.updatedAt) + // A queued command has no ack. Any subsequent snapshot proves the phone + // has resumed communicating; a separate timeout covers the case where + // state dedupe means no snapshot follows. + clearPendingCommand() + + if mapGeoNeeded && !decoded.mapGeoIncluded { + // updateApplicationContext is latest-state-wins, but a context sent + // just before the map reappeared may still win the delivery race. The + // empty arrays are rendered honestly, then a full replacement is + // requested immediately rather than mixing old markers with new state. + // + // `refresh: true` is what makes that a replacement rather than a wish. + // `force` only defeats this client's own "already told the phone that" + // check; the phone still has its payload fingerprint, and by now it has + // usually already sent full geography and cached it. Without the refresh + // the request dedupes into silence and the wrist keeps the empty arrays + // until something unrelated moves the urgency key — which, parked and + // idle, is exactly when it does not. + let lastRequest = lastMapGeoRecoveryRequestAt + if lastRequest == nil || + arrival.timeIntervalSince(lastRequest ?? .distantPast) >= + Self.mapGeoRecoveryThrottle + { + lastMapGeoRecoveryRequestAt = arrival + sendMapGeoPreference(true, force: true, refresh: true) + } + } + + if let cue = decoded.cue, + case let presentation = WatchWireRules.presentation( + for: cue, + at: arrival, + clockOffset: clockOffset + ), + presentation != .drop, + presentedCueIDs.insert(cue.id).inserted + { + presentedCueIDOrder.append(cue.id) + // The phone bounds its command-ID cache for the same reason: a watch + // process can live for days, while only recent redelivery matters. + if presentedCueIDOrder.count > 64 { + presentedCueIDs.remove(presentedCueIDOrder.removeFirst()) + } + // Played here rather than from a view: this branch is already the one + // place that decides a cue is new, fresh, and worth presenting. A view + // would have to re-derive that gate from exported state and would get + // it wrong on redelivery, which WatchConnectivity does routinely. + if presentation == .feel { play(cue) } + if let message = cue.message, !message.isEmpty { + // A cue may be a late wrist-command failure or an unrelated phone + // event; the current wire cannot distinguish them, so attribution + // here would be a guess. Correlating it later requires carrying the + // originating command on `WatchHapticCue` across the wire. + setLastRefusal(message, from: nil) + } + } + } +} + +// MARK: - WCSessionDelegate + +/// Every method here is `nonisolated`, because WatchConnectivity documents that +/// "the methods of this protocol are called on a background thread of your app" +/// and asks that any resulting interface change be redirected to the main +/// thread. `ingest` was already doing that for the payload; the activation path +/// was not, and reached `pendingRefresh`, `lastSentMapGeoNeeded` and +/// `mapGeoSuppressionTask` — all `@Observable`-tracked and all read on the main +/// actor — straight from the delivery queue. +/// +/// Decoding stays off the main actor deliberately. It is the only expensive +/// thing that happens here, it touches no state, and pushing a 12 kB JSON parse +/// onto the wrist's main thread on every snapshot would trade one defect for a +/// worse one. +extension WatchSessionClient: WCSessionDelegate { + nonisolated func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + guard activationState == .activated else { return } + ingest(context: session.receivedApplicationContext) + let reachable = session.isReachable + Task { @MainActor [weak self] in + self?.noteReachability(reachable) + self?.completeActivation() + } + } + + nonisolated func session( + _ session: WCSession, + didReceiveApplicationContext applicationContext: [String: Any] + ) { + ingest(context: applicationContext) + } + + nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + guard let data = message[MeshMapperWatchWire.payloadKey] as? Data else { return } + // The one delivery path whose transit is short enough to date the phone's + // clock against this one. + ingest(data: data, live: true) + } + + /// The documented signal for `isReachable` changing. Without it the property + /// is only ever as current as the last activation. + nonisolated func sessionReachabilityDidChange(_ session: WCSession) { + let reachable = session.isReachable + Task { @MainActor [weak self] in self?.noteReachability(reachable) } + } +} diff --git a/ios/MeshMapperWatch/WatchSettings.swift b/ios/MeshMapperWatch/WatchSettings.swift new file mode 100644 index 00000000..92c3c35d --- /dev/null +++ b/ios/MeshMapperWatch/WatchSettings.swift @@ -0,0 +1,380 @@ +import CoreLocation +import Foundation +import SwiftUI + +/// Wrist-side preferences. +/// +/// These are watch-local on purpose: they describe how this small screen is +/// laid out, not anything about the mapping session, so syncing them from the +/// phone would add a round-trip for no benefit. +@Observable +final class WatchSettings { + private enum Key { + static let showLinks = "map.showLinks" + static let follow = "map.follow" + static let mapLatitudeDelta = "map.latitudeDelta" + static let mapZoomDefaultsVersion = "map.zoomDefaultsVersion" + static let mapLastCenter = "map.lastCenter" + static let mainPageContent = "layout.mainPageContent" + static let nodeListPlacement = "layout.nodeListPlacement" + static let defaultStartMode = "controls.defaultStartMode" + static let showPingWhenAvailable = "controls.showPingWhenAvailable" + static let haptics = "controls.haptics" + } + + /// Roughly 250 m north-south: one degree of latitude is about 111,320 m. + static let defaultMapLatitudeDelta = 0.00225 + /// The Crown's own range is wider than this at both ends, and the two bounds + /// are set for different reasons. + /// + /// **The floor tracks the hardware.** Measured on a Series 9, MapKit's + /// tightest Crown zoom is about 0.000230 degrees (~26 m); the floor used to + /// be 0.0005 (~56 m), so zooming in past that was clamped and the next + /// re-assert snapped the map back out. The wearer reported it as the map not + /// holding position. 0.0002 sits just below the observed limit so the Crown + /// is never fought on the way in. + /// + /// **The ceiling is a policy, not a measurement.** The Crown reaches about + /// 25 degrees (~2,800 km), and persisting that would let the app open showing + /// a continent — visually indistinguishable from the `.automatic` bug this + /// file exists to prevent, just chosen rather than inflicted. 0.5 (~56 km) + /// keeps a stray flick from becoming a sticky state. Zooming out past it + /// still works; only the persisted value is capped. + private static let mapLatitudeDeltaLimits = 0.0002...0.5 + private static let mapZoomDefaultsVersion = 1 + + /// Where the recently-responded list lives. + /// + /// Both layouts are built from one view model, so this is a presentation + /// toggle rather than two code paths. Its own page is the default — chosen + /// for the room it gives the rows — and the sheet stays for wearers who would + /// rather keep the map behind the list. + enum NodeListPlacement: String, CaseIterable, Identifiable { + case sheet + case page + + var id: String { rawValue } + + var label: String { + switch self { + case .sheet: return "Sheet over map" + case .page: return "Its own page" + } + } + } + + /// What occupies the app's first page at full luminance. + /// + /// Reduced luminance remains a separate system condition: either choice can + /// still enter the power-frugal readout when the wrist drops. + enum MainPageContent: String, CaseIterable, Identifiable { + case map + case readout + + var id: String { rawValue } + + var label: String { + switch self { + case .map: return "Map" + case .readout: return "Readout" + } + } + } + + /// The explicit mode a wrist Start will request. The phone remains the final + /// authority and can refuse Hybrid if zone policy changed after the snapshot. + enum DefaultStartMode: String, CaseIterable, Identifiable { + case passive + case hybrid + + var id: String { rawValue } + + var label: String { + switch self { + case .passive: return "Passive" + case .hybrid: return "Hybrid" + } + } + } + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + showLinks = defaults.bool(forKey: Key.showLinks) + // Following the fix is the useful default while driving; absent any + // stored value `bool(forKey:)` returns false, so invert an explicit flag. + follow = defaults.object(forKey: Key.follow) as? Bool ?? true + // Rendered MapKit spans drift from the region requested, so an equality + // test cannot identify the old default reliably. A versioned migration + // deliberately resets every existing install once; after recording the + // version, ordinary Crown write-back owns the value again. + if defaults.integer(forKey: Key.mapZoomDefaultsVersion) < Self.mapZoomDefaultsVersion { + mapLatitudeDelta = Self.defaultMapLatitudeDelta + defaults.set(Self.defaultMapLatitudeDelta, forKey: Key.mapLatitudeDelta) + defaults.set(Self.mapZoomDefaultsVersion, forKey: Key.mapZoomDefaultsVersion) + } else { + // `double(forKey:)` turns absence into zero, which would silently select + // the minimum zoom. Preserve the distinction so an absent value gets the + // deliberate ~250 m default instead. + mapLatitudeDelta = Self.clampedMapLatitudeDelta( + defaults.object(forKey: Key.mapLatitudeDelta) as? Double + ?? Self.defaultMapLatitudeDelta + ) + } + mainPageContent = (defaults.string(forKey: Key.mainPageContent)) + .flatMap(MainPageContent.init(rawValue:)) ?? .map + nodeListPlacement = (defaults.string(forKey: Key.nodeListPlacement)) + .flatMap(NodeListPlacement.init(rawValue:)) ?? .page + defaultStartMode = (defaults.string(forKey: Key.defaultStartMode)) + .flatMap(DefaultStartMode.init(rawValue:)) ?? .passive + showPingWhenAvailable = defaults.object( + forKey: Key.showPingWhenAvailable + ) as? Bool ?? false + // Absent means on, so `bool(forKey:)` cannot be used: it turns a fresh + // install into a silent one. Same inversion as `follow` above. + haptics = defaults.object(forKey: Key.haptics) as? Bool ?? true + } + + /// Draw a line from the fix to each repeater that answered the last ping. + var showLinks: Bool { + didSet { defaults.set(showLinks, forKey: Key.showLinks) } + } + + /// Keep the camera on the phone's position. + var follow: Bool { + didSet { defaults.set(follow, forKey: Key.follow) } + } + + /// Only latitude is persisted. MapKit fits longitude to the watch's aspect + /// ratio, so storing both would preserve two values that are not independent. + /// Normalising before persistence keeps a bad Crown result from becoming a + /// sticky launch state. + var mapLatitudeDelta: Double { + didSet { + let clamped = Self.clampedMapLatitudeDelta(mapLatitudeDelta) + if clamped != mapLatitudeDelta { + mapLatitudeDelta = clamped + } + // Persist the normalised value explicitly rather than relying on an + // assignment inside `didSet` to invoke the observer a second time. + defaults.set(clamped, forKey: Key.mapLatitudeDelta) + } + } + + /// Where the camera last rested, so a map with no live fix opens on the last + /// place the wearer actually saw rather than on MapKit's annotation fit. + /// + /// `.automatic` is not a neutral starting state: measured on a Series 9 it + /// renders 34.4 degrees of latitude, about 3,800 km, and once the camera + /// lands there nothing in `MapPage` moves it back. This value is the seed + /// that keeps that from being reachable — see `anchorCameraIfNeeded`. + /// + /// Deliberately *not* `@Observable`-backed state: nothing renders from it, it + /// is read once per native-map lifetime, and making it observable would + /// invalidate the map on every GPS step it records. + /// + /// Absence is distinguished from zero the same way the span is. `0, 0` is a + /// real coordinate in the Gulf of Guinea, so treating a missing key as zero + /// would open a fresh install in the Atlantic rather than falling through to + /// the live fix. + /// + /// Stored as one array rather than a latitude key, a longitude key and a + /// timestamp key. Separate writes can be torn apart by the app being killed + /// between them — watchOS terminates this app freely, which is the whole + /// reason the value is persisted — and half a coordinate is a + /// plausible-looking centre somewhere on the equator or the prime meridian. + /// One key cannot tear. + var lastMapCenter: CLLocationCoordinate2D? { + storedMapCenter?.center + } + + /// The stored centre only while it still plausibly describes where the wearer + /// is, which is what lets a live fix outrank it. + /// + /// Without an age, a centre saved in another city wins over a real fix + /// forever once Follow is off. That matters more here than it would on the + /// phone, because `interactionModes` is `[.zoom]` — the wearer cannot pan out + /// of a wrong place, only zoom within it. + /// + /// Twelve hours is chosen to survive a full session and a break, while not + /// surviving a night's travel. Note that a fresh centre and a live fix agree + /// whenever the wearer has not moved, so this threshold only decides the case + /// where they *have* — which is exactly the case the live fix answers better. + var freshMapCenter: CLLocationCoordinate2D? { + guard let stored = storedMapCenter, + let savedAt = stored.savedAt, + Date().timeIntervalSince1970 - savedAt < Self.mapCenterFreshnessSeconds + else { return nil } + return stored.center + } + + private static let mapCenterFreshnessSeconds: TimeInterval = 12 * 60 * 60 + + /// A missing timestamp is treated as unknown age rather than as fresh, so an + /// older two-element value can never masquerade as current. + private var storedMapCenter: (center: CLLocationCoordinate2D, savedAt: TimeInterval?)? { + guard + let stored = defaults.array(forKey: Key.mapLastCenter) as? [Double], + stored.count >= 2 + else { return nil } + let (lat, lon) = (stored[0], stored[1]) + guard + lat.isFinite, lon.isFinite, + (-90...90).contains(lat), (-180...180).contains(lon) + else { return nil } + let savedAt = stored.count >= 3 && stored[2].isFinite ? stored[2] : nil + return (CLLocationCoordinate2D(latitude: lat, longitude: lon), savedAt) + } + + /// Record a centre the camera was actually driven to. + /// + /// Throttled because follow asserts a region on every geo update, and the + /// phone's own `minMoveMeters` filter (15 m) is *finer* than anything this + /// value needs — left unthrottled at walking pace this would write roughly + /// once every 11 seconds for the life of a session, against active battery + /// work. + /// + /// A coarse threshold is affordable because this is only ever read at app + /// launch: within a session `MapPage` holds the live centre in memory and + /// never consults this. So the stored value only has to be good enough to + /// open the map in the right place, and it is competing against `.automatic` + /// at 3,800 km — not against a perfect restore. + func noteMapCenter(_ center: CLLocationCoordinate2D) { + guard + center.latitude.isFinite, center.longitude.isFinite, + (-90...90).contains(center.latitude), + (-180...180).contains(center.longitude) + else { return } + + // Freshness is part of the skip test, not just position. A wearer who stays + // put would otherwise match the stored coordinates forever, never rewrite + // the entry, and so let its timestamp expire under an accurate centre — + // the map would then decline to use a position that never stopped being + // correct. Requiring freshness here costs one write per expiry window. + if let stored = lastMapCenter, freshMapCenter != nil, + abs(stored.latitude - center.latitude) < Self.mapCenterWriteThreshold, + abs(stored.longitude - center.longitude) < Self.mapCenterWriteThreshold + { + return + } + defaults.set( + [center.latitude, center.longitude, Date().timeIntervalSince1970], + forKey: Key.mapLastCenter + ) + } + + /// About 110 m of latitude. Degrees, not metres: longitude convergence is + /// irrelevant to a write throttle, and treating it as a distance would imply + /// a precision this value does not need. + private static let mapCenterWriteThreshold = 0.001 + + var mainPageContent: MainPageContent { + didSet { defaults.set(mainPageContent.rawValue, forKey: Key.mainPageContent) } + } + + var nodeListPlacement: NodeListPlacement { + didSet { defaults.set(nodeListPlacement.rawValue, forKey: Key.nodeListPlacement) } + } + + var defaultStartMode: DefaultStartMode { + didSet { defaults.set(defaultStartMode.rawValue, forKey: Key.defaultStartMode) } + } + + var showPingWhenAvailable: Bool { + didSet { defaults.set(showPingWhenAvailable, forKey: Key.showPingWhenAvailable) } + } + + /// Whether a phone-issued cue is allowed to reach the Taptic Engine. + /// + /// Wrist-local like everything else here, and for a stronger reason than + /// layout: the wearer is the person being buzzed, and a phone round-trip + /// would make silencing it depend on the very link that just failed. + /// + /// On by default because the only cue the phone currently sends is a + /// `failure` for a wrist command it had already accepted — rare, never part + /// of a routine ping cycle, and always the direct consequence of something + /// the wearer just tapped. + var haptics: Bool { + didSet { defaults.set(haptics, forKey: Key.haptics) } + } + + #if DEBUG + /// Freeze the countdown bar's drain, for measuring what that animation costs. + /// + /// Read statically rather than through an instance so the animation path does + /// not take an observation dependency on it: flipping this must change the + /// next phase, not invalidate the view mid-drain and perturb the very trace + /// it exists to produce. + static var debugFreezesTimerBar: Bool { + UserDefaults.standard.bool(forKey: "MeshMapperFreezeTimerBar") + } + + var freezesTimerBar: Bool { + get { Self.debugFreezesTimerBar } + set { defaults.set(newValue, forKey: "MeshMapperFreezeTimerBar") } + } + + /// Record wrist-raise timings to `WakeLog`. + /// + /// **Persisted deliberately, and this is the whole point.** The same job was + /// first done by a `-MeshMapperTimeSwitchToMap` launch argument, which cannot + /// work here: launch arguments live in `NSArgumentDomain` and evaporate the + /// moment watchOS terminates and relaunches the app — which is exactly what a + /// wrist-down test provokes. The gate closed silently and the run produced an + /// empty log with nothing to indicate why. A measurement switch that outlives + /// process death has to be persisted, and being on the wrist means it can be + /// flipped without a reinstall. + static var debugLogsWakeTiming: Bool { + UserDefaults.standard.bool(forKey: "MeshMapperLogWakeTiming") + || UserDefaults.standard.bool(forKey: "MeshMapperTimeSwitchToMap") + } + + var logsWakeTiming: Bool { + get { Self.debugLogsWakeTiming } + set { defaults.set(newValue, forKey: "MeshMapperLogWakeTiming") } + } + #endif + + /// One promise shared by Settings and the explicit-mode Start control. If + /// these surfaces resolve independently, Settings can claim Hybrid while a + /// tap silently requests Passive — precisely the kind of mode ambiguity the + /// explicit command field exists to prevent. + func effectiveStartMode( + availableStartModes: [String]? + ) -> DefaultStartMode { + let advertised = availableStartModes ?? [DefaultStartMode.passive.rawValue] + return advertised.contains(defaultStartMode.rawValue) + ? defaultStartMode + : .passive + } + + private static func clampedMapLatitudeDelta(_ value: Double) -> Double { + guard value.isFinite else { return defaultMapLatitudeDelta } + return min(max(value, mapLatitudeDeltaLimits.lowerBound), mapLatitudeDeltaLimits.upperBound) + } +} + +/// Shared control chrome, lifted from the phone rather than accumulating +/// unrelated system tints across wrist surfaces. Ping and repeater data colours +/// are different: those arrive resolved through the phone's colour-vision +/// palette, while these action semantics remain fixed just as today's red and +/// green buttons do. Revisit this boundary if watch controls become palette-aware. +enum WatchPalette { + static let start = Color(red: 34 / 255, green: 197 / 255, blue: 94 / 255) + static let stop = Color(red: 189 / 255, green: 33 / 255, blue: 48 / 255) + static let ping = Color(red: 99 / 255, green: 102 / 255, blue: 241 / 255) + static let armed = Color(red: 245 / 255, green: 158 / 255, blue: 11 / 255) + static let disabled = Color(red: 51 / 255, green: 65 / 255, blue: 85 / 255) + static let secondary = Color(red: 71 / 255, green: 85 / 255, blue: 105 / 255) + static let tertiary = Color(red: 148 / 255, green: 163 / 255, blue: 184 / 255) + static let cornerRadius: CGFloat = 12 +} + +extension Color { + /// Colours arrive already resolved from the phone's colour-vision palette, + /// so the watch never needs to know which palette is active. + init(_ watchColor: WatchColor) { + self.init(red: watchColor.r, green: watchColor.g, blue: watchColor.b) + } +} diff --git a/ios/Podfile b/ios/Podfile index 620e46eb..22d33ba4 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -39,5 +39,13 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) + target.build_configurations.each do |config| + # Clang module verifier builds each pod's module in isolation, where the + # `Flutter` module isn't on the search path. Swift plugins (e.g. + # disk_space_plus) import Flutter in their generated -Swift.h, so the + # verifier fails with "module 'Flutter' not found". Disable it for pods. + # (Xcode's "Update to recommended settings" silently turns this on.) + config.build_settings['ENABLE_MODULE_VERIFIER'] = 'NO' + end end end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 5a4c7131..10431777 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,145 +1,46 @@ PODS: - - audio_session (0.0.1): - - Flutter - - DKImagePickerController/Core (4.3.9): - - DKImagePickerController/ImageDataManager - - DKImagePickerController/Resource - - DKImagePickerController/ImageDataManager (4.3.9) - - DKImagePickerController/PhotoGallery (4.3.9): - - DKImagePickerController/Core - - DKPhotoGallery - - DKImagePickerController/Resource (4.3.9) - - DKPhotoGallery (0.0.19): - - DKPhotoGallery/Core (= 0.0.19) - - DKPhotoGallery/Model (= 0.0.19) - - DKPhotoGallery/Preview (= 0.0.19) - - DKPhotoGallery/Resource (= 0.0.19) - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Core (0.0.19): - - DKPhotoGallery/Model - - DKPhotoGallery/Preview - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Model (0.0.19): - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Preview (0.0.19): - - DKPhotoGallery/Model - - DKPhotoGallery/Resource - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Resource (0.0.19): - - SDWebImage - - SwiftyGif - - file_picker (0.0.1): - - DKImagePickerController/PhotoGallery + - disk_space_plus (0.0.1): - Flutter - Flutter (1.0.0) - flutter_background_service_ios (0.0.3): - Flutter - - flutter_blue_plus_darwin (0.0.2): - - Flutter - - FlutterMacOS - flutter_local_notifications (0.0.1): - Flutter - - geolocator_apple (1.2.0): - - Flutter - - FlutterMacOS - - just_audio (0.0.1): - - Flutter - - FlutterMacOS - - package_info_plus (0.4.5): + - flutter_secure_storage (6.0.0): - Flutter - permission_handler_apple (9.3.0): - Flutter - - SDWebImage (5.21.5): - - SDWebImage/Core (= 5.21.5) - - SDWebImage/Core (5.21.5) - - share_plus (0.0.1): - - Flutter - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - - SwiftyGif (5.4.5) - - url_launcher_ios (0.0.1): - - Flutter - - wakelock_plus (0.0.1): - - Flutter DEPENDENCIES: - - audio_session (from `.symlinks/plugins/audio_session/ios`) - - file_picker (from `.symlinks/plugins/file_picker/ios`) + - disk_space_plus (from `.symlinks/plugins/disk_space_plus/ios`) - Flutter (from `Flutter`) - flutter_background_service_ios (from `.symlinks/plugins/flutter_background_service_ios/ios`) - - flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) - - just_audio (from `.symlinks/plugins/just_audio/darwin`) - - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - - share_plus (from `.symlinks/plugins/share_plus/ios`) - - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) - -SPEC REPOS: - trunk: - - DKImagePickerController - - DKPhotoGallery - - SDWebImage - - SwiftyGif EXTERNAL SOURCES: - audio_session: - :path: ".symlinks/plugins/audio_session/ios" - file_picker: - :path: ".symlinks/plugins/file_picker/ios" + disk_space_plus: + :path: ".symlinks/plugins/disk_space_plus/ios" Flutter: :path: Flutter flutter_background_service_ios: :path: ".symlinks/plugins/flutter_background_service_ios/ios" - flutter_blue_plus_darwin: - :path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin" flutter_local_notifications: :path: ".symlinks/plugins/flutter_local_notifications/ios" - geolocator_apple: - :path: ".symlinks/plugins/geolocator_apple/darwin" - just_audio: - :path: ".symlinks/plugins/just_audio/darwin" - package_info_plus: - :path: ".symlinks/plugins/package_info_plus/ios" + flutter_secure_storage: + :path: ".symlinks/plugins/flutter_secure_storage/ios" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" - share_plus: - :path: ".symlinks/plugins/share_plus/ios" - shared_preferences_foundation: - :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - url_launcher_ios: - :path: ".symlinks/plugins/url_launcher_ios/ios" - wakelock_plus: - :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: - audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 - DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c - DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 - file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be + disk_space_plus: a36391fb5f732dbdd29628b0bac5a1acbd43aaef Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e - flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 - geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e - just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed - package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d - SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838 - share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a - shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb - SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 - url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b - wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 -PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e +PODFILE CHECKSUM: 1b71fceb7f7c8618409f0f855f3558d90a1d2b33 COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 60334cff..5064383f 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -7,15 +7,58 @@ objects = { /* Begin PBXBuildFile section */ + 05408ED93BBD28C6335AD1A3 /* DebugPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C9115E48EC1A1ABBADD7C4 /* DebugPage.swift */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 15EB8B11C186344E7D096C70 /* MeshMapperWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 74331FACF5FD72D49FF952AD /* MeshMapperWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 2E286CDEFC491253FF7615D3 /* NodeListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 775B3F6C0618B4BD5644B40B /* NodeListView.swift */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + C10000000000000000000001 /* AppIntentLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000001 /* AppIntentLogicTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 404769CF7E177D96A79BEA75 /* SettingsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84205AF3825B4E2EF987B27E /* SettingsPage.swift */; }; + 4FB810C0D8676DD8AB0B1B30 /* WatchSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB902C46F9528E4D932613C /* WatchSessionManager.swift */; }; + 54E8A5C4C2081092E8CE145A /* WatchSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8523E9AE78A9549CE601F697 /* WatchSettings.swift */; }; + 57C05889BAC05567849ED416 /* MapPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EA64785988022009D0587B1 /* MapPage.swift */; }; + 6AF9D4D984EEF729333DD5B5 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AD2A4CC4FD28C0416F14D0 /* ContentView.swift */; }; + 7288D34A9BC1C140A95C5ED9 /* SampleSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10A44B241BC20153F36C053D /* SampleSnapshot.swift */; }; 73F9D344DD4B7EC7AC29CD86 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F799CC3DB45F3F5C30B5907D /* Pods_RunnerTests.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 75D889865C3C829654189002 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8CAA1E3000FEC19EE9ED5CFE /* Pods_Runner.framework */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 91E86E700B648483F172FAA5 /* ControlsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD851ABE7C5AD5DBCEBDE26 /* ControlsPage.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 */; }; + A10000000000000000000001 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000001 /* LiveActivityManager.swift */; }; + A10000000000000000000002 /* MeshMapperActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000002 /* MeshMapperActivityAttributes.swift */; }; + A10000000000000000000003 /* MeshMapperActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000002 /* MeshMapperActivityAttributes.swift */; }; + A10000000000000000000004 /* MeshMapperLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000003 /* MeshMapperLiveActivity.swift */; }; + A10000000000000000000009 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000009 /* Assets.xcassets */; }; + A10000000000000000000005 /* MeshMapperLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000004 /* MeshMapperLiveActivityBundle.swift */; }; + A10000000000000000000006 /* MeshMapperLiveActivityExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A20000000000000000000006 /* MeshMapperLiveActivityExtension.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + A40A14B14EA7033DDEF33B80 /* WatchSessionClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 178C77B846A00943CD881203 /* WatchSessionClient.swift */; }; + B69988072090B261D65915C7 /* MeshMapperWatchPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68B2C4905F4EEE82DDA8825A /* MeshMapperWatchPayload.swift */; }; + E6EDFA2E3EBEDBAFD72A5B9F /* MeshMapperWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87786D0E1C88A11BAB16DA95 /* MeshMapperWatchApp.swift */; }; + E83718073D76FB741949ED05 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6CC8647C002484845F02D0CE /* Assets.xcassets */; }; + F857D97D425B45AB76FD4B2F /* MeshMapperWatchPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68B2C4905F4EEE82DDA8825A /* MeshMapperWatchPayload.swift */; }; + B10000000000000000000001 /* SiriIntentCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000001 /* SiriIntentCoordinator.swift */; }; + B10000000000000000000002 /* MeshMapperSessionMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000002 /* MeshMapperSessionMode.swift */; }; + B10000000000000000000003 /* StartMeshMapperSessionIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000003 /* StartMeshMapperSessionIntent.swift */; }; + B10000000000000000000004 /* StopMeshMapperSessionIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000004 /* StopMeshMapperSessionIntent.swift */; }; + B10000000000000000000005 /* SendManualPingIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000005 /* SendManualPingIntent.swift */; }; + B10000000000000000000006 /* MeshMapperAppShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000006 /* MeshMapperAppShortcuts.swift */; }; + B10000000000000000000010 /* ConnectLastMeshMapperCompanionIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000011 /* ConnectLastMeshMapperCompanionIntent.swift */; }; + B10000000000000000000007 /* MeshMapperSiriSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000007 /* MeshMapperSiriSnapshot.swift */; }; + B10000000000000000000008 /* MeshMapperSiriSnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000008 /* MeshMapperSiriSnapshotStore.swift */; }; + B10000000000000000000009 /* MeshMapperAppIntentsExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000C /* MeshMapperAppIntentsExtension.swift */; }; + B1000000000000000000000A /* MeshMapperReadIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000B /* MeshMapperReadIntents.swift */; }; + B1000000000000000000000B /* MeshMapperSiriSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000007 /* MeshMapperSiriSnapshot.swift */; }; + B1000000000000000000000C /* MeshMapperSiriSnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000008 /* MeshMapperSiriSnapshotStore.swift */; }; + B1000000000000000000000D /* RepeaterEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000009 /* RepeaterEntity.swift */; }; + B1000000000000000000000E /* HeardRepeaterEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000A /* HeardRepeaterEntity.swift */; }; + B1000000000000000000001A /* MeshMapperReadIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000B /* MeshMapperReadIntents.swift */; }; + B1000000000000000000001B /* RepeaterEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000009 /* RepeaterEntity.swift */; }; + B1000000000000000000001C /* HeardRepeaterEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000A /* HeardRepeaterEntity.swift */; }; + B1000000000000000000000F /* MeshMapperAppIntents.appex in Embed ExtensionKit Extensions */ = {isa = PBXBuildFile; fileRef = B20000000000000000000010 /* MeshMapperAppIntents.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -26,9 +69,41 @@ remoteGlobalIDString = 97C146ED1CF9000F007C117D; remoteInfo = Runner; }; + 964501C97A62900A93BB2E74 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 26F4F6F7B0F55BC605124558; + remoteInfo = MeshMapperWatch; + }; + A60000000000000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = A50000000000000000000001; + remoteInfo = MeshMapperLiveActivityExtension; + }; + B60000000000000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = B50000000000000000000001; + remoteInfo = MeshMapperAppIntents; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + 6D0B327AA07726C8BA4AA892 /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + 15EB8B11C186344E7D096C70 /* MeshMapperWatch.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -39,19 +114,57 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + A40000000000000000000004 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + A10000000000000000000006 /* MeshMapperLiveActivityExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; + B40000000000000000000004 /* Embed ExtensionKit Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(EXTENSIONS_FOLDER_PATH)"; + dstSubfolderSpec = 16; + files = ( + B1000000000000000000000F /* MeshMapperAppIntents.appex in Embed ExtensionKit Extensions */, + ); + name = "Embed ExtensionKit Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 10A44B241BC20153F36C053D /* SampleSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SampleSnapshot.swift; sourceTree = ""; }; + 111B35B32FAB66FA2E78E0BE /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; 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 = ""; }; + 178C77B846A00943CD881203 /* WatchSessionClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSessionClient.swift; sourceTree = ""; }; + 1FD851ABE7C5AD5DBCEBDE26 /* ControlsPage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ControlsPage.swift; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + C20000000000000000000001 /* AppIntentLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentLogicTests.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 = ""; }; + 3EA64785988022009D0587B1 /* MapPage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MapPage.swift; sourceTree = ""; }; + 4DB902C46F9528E4D932613C /* WatchSessionManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSessionManager.swift; sourceTree = ""; }; 6316620B3FF7A48DF8F886CA /* 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 = ""; }; + 64AD2A4CC4FD28C0416F14D0 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 68B2C4905F4EEE82DDA8825A /* MeshMapperWatchPayload.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MeshMapperWatchPayload.swift; sourceTree = ""; }; + 6CC8647C002484845F02D0CE /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 74331FACF5FD72D49FF952AD /* MeshMapperWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MeshMapperWatch.app; 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 = ""; }; + 775B3F6C0618B4BD5644B40B /* NodeListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NodeListView.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 84205AF3825B4E2EF987B27E /* SettingsPage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsPage.swift; sourceTree = ""; }; + 8523E9AE78A9549CE601F697 /* WatchSettings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSettings.swift; sourceTree = ""; }; 86A4D8E4F5F13D005DA717B4 /* 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 = ""; }; + 87786D0E1C88A11BAB16DA95 /* MeshMapperWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MeshMapperWatchApp.swift; sourceTree = ""; }; 8BA04B67488852DCDA49C863 /* 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 = ""; }; 8CAA1E3000FEC19EE9ED5CFE /* 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 = ""; }; @@ -61,13 +174,47 @@ 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 = ""; }; + 98C9115E48EC1A1ABBADD7C4 /* DebugPage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DebugPage.swift; sourceTree = ""; }; + A20000000000000000000001 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = ""; }; + A20000000000000000000002 /* MeshMapperActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperActivityAttributes.swift; sourceTree = ""; }; + A20000000000000000000003 /* MeshMapperLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperLiveActivity.swift; sourceTree = ""; }; + A20000000000000000000009 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A20000000000000000000004 /* MeshMapperLiveActivityBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperLiveActivityBundle.swift; sourceTree = ""; }; + A20000000000000000000005 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A20000000000000000000006 /* MeshMapperLiveActivityExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MeshMapperLiveActivityExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + A20000000000000000000007 /* LiveActivity.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = LiveActivity.xcconfig; path = Flutter/LiveActivity.xcconfig; sourceTree = ""; }; + A20000000000000000000008 /* Watch.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Watch.xcconfig; path = Flutter/Watch.xcconfig; sourceTree = ""; }; B1A260696FEFA53606484258 /* 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 = ""; }; C5DCC2A7546C7F71461B567A /* 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 = ""; }; D478469A7D705340684EFF2B /* 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 = ""; }; F799CC3DB45F3F5C30B5907D /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + B20000000000000000000001 /* SiriIntentCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiriIntentCoordinator.swift; sourceTree = ""; }; + B20000000000000000000002 /* MeshMapperSessionMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperSessionMode.swift; sourceTree = ""; }; + B20000000000000000000003 /* StartMeshMapperSessionIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartMeshMapperSessionIntent.swift; sourceTree = ""; }; + B20000000000000000000004 /* StopMeshMapperSessionIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopMeshMapperSessionIntent.swift; sourceTree = ""; }; + B20000000000000000000005 /* SendManualPingIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendManualPingIntent.swift; sourceTree = ""; }; + B20000000000000000000006 /* MeshMapperAppShortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperAppShortcuts.swift; sourceTree = ""; }; + B20000000000000000000007 /* MeshMapperSiriSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperSiriSnapshot.swift; sourceTree = ""; }; + B20000000000000000000008 /* MeshMapperSiriSnapshotStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperSiriSnapshotStore.swift; sourceTree = ""; }; + B20000000000000000000009 /* RepeaterEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepeaterEntity.swift; sourceTree = ""; }; + B2000000000000000000000A /* HeardRepeaterEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeardRepeaterEntity.swift; sourceTree = ""; }; + B2000000000000000000000B /* MeshMapperReadIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperReadIntents.swift; sourceTree = ""; }; + B2000000000000000000000C /* MeshMapperAppIntentsExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperAppIntentsExtension.swift; sourceTree = ""; }; + B2000000000000000000000D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B2000000000000000000000E /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; + B2000000000000000000000F /* MeshMapperAppIntents.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MeshMapperAppIntents.entitlements; sourceTree = ""; }; + B20000000000000000000010 /* MeshMapperAppIntents.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.extensionkit-extension"; includeInIndex = 0; path = MeshMapperAppIntents.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + B20000000000000000000011 /* ConnectLastMeshMapperCompanionIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectLastMeshMapperCompanionIntent.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 27F55F79605B24B31D20B683 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 507BD8685B0F59BD768C20E3 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -80,10 +227,25 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, 75D889865C3C829654189002 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; + A40000000000000000000002 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B40000000000000000000002 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -92,14 +254,43 @@ children = ( 8CAA1E3000FEC19EE9ED5CFE /* Pods_Runner.framework */, F799CC3DB45F3F5C30B5907D /* Pods_RunnerTests.framework */, + 321ECD9614BDCAFCF545D162 /* watchOS */, ); name = Frameworks; sourceTree = ""; }; + 2CEC7BCDA4A562621033D8AD /* MeshMapperWatch */ = { + isa = PBXGroup; + children = ( + 87786D0E1C88A11BAB16DA95 /* MeshMapperWatchApp.swift */, + 64AD2A4CC4FD28C0416F14D0 /* ContentView.swift */, + 111B35B32FAB66FA2E78E0BE /* Info.plist */, + 6CC8647C002484845F02D0CE /* Assets.xcassets */, + 178C77B846A00943CD881203 /* WatchSessionClient.swift */, + 3EA64785988022009D0587B1 /* MapPage.swift */, + 98C9115E48EC1A1ABBADD7C4 /* DebugPage.swift */, + 84205AF3825B4E2EF987B27E /* SettingsPage.swift */, + 8523E9AE78A9549CE601F697 /* WatchSettings.swift */, + 10A44B241BC20153F36C053D /* SampleSnapshot.swift */, + 775B3F6C0618B4BD5644B40B /* NodeListView.swift */, + 1FD851ABE7C5AD5DBCEBDE26 /* ControlsPage.swift */, + ); + name = MeshMapperWatch; + path = MeshMapperWatch; + sourceTree = ""; + }; + 321ECD9614BDCAFCF545D162 /* watchOS */ = { + isa = PBXGroup; + children = ( + ); + name = watchOS; + sourceTree = ""; + }; 331C8082294A63A400263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( 331C807B294A618700263BE5 /* RunnerTests.swift */, + C20000000000000000000001 /* AppIntentLogicTests.swift */, ); path = RunnerTests; sourceTree = ""; @@ -120,10 +311,13 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, + A20000000000000000000007 /* LiveActivity.xcconfig */, + A20000000000000000000008 /* Watch.xcconfig */, ); name = Flutter; sourceTree = ""; @@ -133,10 +327,14 @@ children = ( 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, + A30000000000000000000001 /* Shared */, + A30000000000000000000002 /* MeshMapperLiveActivity */, + B30000000000000000000003 /* MeshMapperAppIntents */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, 88C2A374596BDB4F1DE4A0B4 /* Pods */, 1E85EC0983B84C8E2376ABCD /* Frameworks */, + 2CEC7BCDA4A562621033D8AD /* MeshMapperWatch */, ); sourceTree = ""; }; @@ -145,6 +343,9 @@ children = ( 97C146EE1CF9000F007C117D /* Runner.app */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + A20000000000000000000006 /* MeshMapperLiveActivityExtension.appex */, + 74331FACF5FD72D49FF952AD /* MeshMapperWatch.app */, + B20000000000000000000010 /* MeshMapperAppIntents.appex */, ); name = Products; sourceTree = ""; @@ -152,6 +353,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + B30000000000000000000001 /* AppIntents */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, @@ -159,14 +361,91 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + A20000000000000000000001 /* LiveActivityManager.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + 4DB902C46F9528E4D932613C /* WatchSessionManager.swift */, + B2000000000000000000000E /* Runner.entitlements */, ); path = Runner; sourceTree = ""; }; + A30000000000000000000001 /* Shared */ = { + isa = PBXGroup; + children = ( + B30000000000000000000002 /* AppIntents */, + A20000000000000000000002 /* MeshMapperActivityAttributes.swift */, + 68B2C4905F4EEE82DDA8825A /* MeshMapperWatchPayload.swift */, + ); + path = Shared; + sourceTree = ""; + }; + A30000000000000000000002 /* MeshMapperLiveActivity */ = { + isa = PBXGroup; + children = ( + A20000000000000000000003 /* MeshMapperLiveActivity.swift */, + A20000000000000000000009 /* Assets.xcassets */, + A20000000000000000000004 /* MeshMapperLiveActivityBundle.swift */, + A20000000000000000000005 /* Info.plist */, + ); + path = MeshMapperLiveActivity; + sourceTree = ""; + }; + B30000000000000000000001 /* AppIntents */ = { + isa = PBXGroup; + children = ( + B20000000000000000000001 /* SiriIntentCoordinator.swift */, + B20000000000000000000002 /* MeshMapperSessionMode.swift */, + B20000000000000000000003 /* StartMeshMapperSessionIntent.swift */, + B20000000000000000000004 /* StopMeshMapperSessionIntent.swift */, + B20000000000000000000005 /* SendManualPingIntent.swift */, + B20000000000000000000006 /* MeshMapperAppShortcuts.swift */, + B20000000000000000000011 /* ConnectLastMeshMapperCompanionIntent.swift */, + ); + path = AppIntents; + sourceTree = ""; + }; + B30000000000000000000002 /* AppIntents */ = { + isa = PBXGroup; + children = ( + B20000000000000000000007 /* MeshMapperSiriSnapshot.swift */, + B20000000000000000000008 /* MeshMapperSiriSnapshotStore.swift */, + B20000000000000000000009 /* RepeaterEntity.swift */, + B2000000000000000000000A /* HeardRepeaterEntity.swift */, + ); + path = AppIntents; + sourceTree = ""; + }; + B30000000000000000000003 /* MeshMapperAppIntents */ = { + isa = PBXGroup; + children = ( + B2000000000000000000000B /* MeshMapperReadIntents.swift */, + B2000000000000000000000C /* MeshMapperAppIntentsExtension.swift */, + B2000000000000000000000D /* Info.plist */, + B2000000000000000000000F /* MeshMapperAppIntents.entitlements */, + ); + path = MeshMapperAppIntents; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 26F4F6F7B0F55BC605124558 /* MeshMapperWatch */ = { + isa = PBXNativeTarget; + buildConfigurationList = E32607468DAE38F2F71EADD5 /* Build configuration list for PBXNativeTarget "MeshMapperWatch" */; + buildPhases = ( + FBC0EF55C50DCA90E416F7D1 /* Sources */, + 27F55F79605B24B31D20B683 /* Frameworks */, + 4039BA5EEB0F88665D8EAFE2 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = MeshMapperWatch; + productName = MeshMapperWatch; + productReference = 74331FACF5FD72D49FF952AD /* MeshMapperWatch.app */; + productType = "com.apple.product-type.application"; + }; 331C8080294A63A400263BE5 /* RunnerTests */ = { isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; @@ -196,6 +475,9 @@ 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, + A40000000000000000000004 /* Embed Foundation Extensions */, + B40000000000000000000004 /* Embed ExtensionKit Extensions */, + 6D0B327AA07726C8BA4AA892 /* Embed Watch Content */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, FF739F0FAE03970EB94871F2 /* [CP] Embed Pods Frameworks */, 1706FFD683EE65B4FC7A3867 /* [CP] Copy Pods Resources */, @@ -203,12 +485,52 @@ buildRules = ( ); dependencies = ( + A70000000000000000000001 /* PBXTargetDependency */, + B70000000000000000000001 /* PBXTargetDependency */, + F203972D6336527AC5F4F89D /* PBXTargetDependency */, ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; + A50000000000000000000001 /* MeshMapperLiveActivityExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = A90000000000000000000001 /* Build configuration list for PBXNativeTarget "MeshMapperLiveActivityExtension" */; + buildPhases = ( + A40000000000000000000001 /* Sources */, + A40000000000000000000002 /* Frameworks */, + A40000000000000000000003 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = MeshMapperLiveActivityExtension; + productName = MeshMapperLiveActivityExtension; + productReference = A20000000000000000000006 /* MeshMapperLiveActivityExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; + B50000000000000000000001 /* MeshMapperAppIntents */ = { + isa = PBXNativeTarget; + buildConfigurationList = B90000000000000000000001 /* Build configuration list for PBXNativeTarget "MeshMapperAppIntents" */; + buildPhases = ( + B40000000000000000000001 /* Sources */, + B40000000000000000000002 /* Frameworks */, + B40000000000000000000003 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = MeshMapperAppIntents; + productName = MeshMapperAppIntents; + productReference = B20000000000000000000010 /* MeshMapperAppIntents.appex */; + productType = "com.apple.product-type.extensionkit-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -227,6 +549,12 @@ CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; }; + A50000000000000000000001 = { + CreatedOnToolsVersion = 15.1; + }; + B50000000000000000000001 = { + CreatedOnToolsVersion = 26.0; + }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; @@ -238,12 +566,18 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, 331C8080294A63A400263BE5 /* RunnerTests */, + A50000000000000000000001 /* MeshMapperLiveActivityExtension */, + B50000000000000000000001 /* MeshMapperAppIntents */, + 26F4F6F7B0F55BC605124558 /* MeshMapperWatch */, ); }; /* End PBXProject section */ @@ -256,6 +590,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4039BA5EEB0F88665D8EAFE2 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E83718073D76FB741949ED05 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -267,6 +609,21 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A40000000000000000000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000000000000000000009 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B40000000000000000000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -387,6 +744,7 @@ buildActionMask = 2147483647; files = ( 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + C10000000000000000000001 /* AppIntentLogicTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -395,7 +753,64 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + A10000000000000000000001 /* LiveActivityManager.swift in Sources */, + A10000000000000000000002 /* MeshMapperActivityAttributes.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + F857D97D425B45AB76FD4B2F /* MeshMapperWatchPayload.swift in Sources */, + 4FB810C0D8676DD8AB0B1B30 /* WatchSessionManager.swift in Sources */, + B10000000000000000000001 /* SiriIntentCoordinator.swift in Sources */, + B10000000000000000000002 /* MeshMapperSessionMode.swift in Sources */, + B10000000000000000000003 /* StartMeshMapperSessionIntent.swift in Sources */, + B10000000000000000000004 /* StopMeshMapperSessionIntent.swift in Sources */, + B10000000000000000000005 /* SendManualPingIntent.swift in Sources */, + B10000000000000000000006 /* MeshMapperAppShortcuts.swift in Sources */, + B10000000000000000000010 /* ConnectLastMeshMapperCompanionIntent.swift in Sources */, + B10000000000000000000007 /* MeshMapperSiriSnapshot.swift in Sources */, + B10000000000000000000008 /* MeshMapperSiriSnapshotStore.swift in Sources */, + B1000000000000000000001A /* MeshMapperReadIntents.swift in Sources */, + B1000000000000000000001B /* RepeaterEntity.swift in Sources */, + B1000000000000000000001C /* HeardRepeaterEntity.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A40000000000000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000000000000000000003 /* MeshMapperActivityAttributes.swift in Sources */, + A10000000000000000000004 /* MeshMapperLiveActivity.swift in Sources */, + A10000000000000000000005 /* MeshMapperLiveActivityBundle.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FBC0EF55C50DCA90E416F7D1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E6EDFA2E3EBEDBAFD72A5B9F /* MeshMapperWatchApp.swift in Sources */, + 6AF9D4D984EEF729333DD5B5 /* ContentView.swift in Sources */, + B69988072090B261D65915C7 /* MeshMapperWatchPayload.swift in Sources */, + A40A14B14EA7033DDEF33B80 /* WatchSessionClient.swift in Sources */, + 57C05889BAC05567849ED416 /* MapPage.swift in Sources */, + 05408ED93BBD28C6335AD1A3 /* DebugPage.swift in Sources */, + 404769CF7E177D96A79BEA75 /* SettingsPage.swift in Sources */, + 54E8A5C4C2081092E8CE145A /* WatchSettings.swift in Sources */, + 7288D34A9BC1C140A95C5ED9 /* SampleSnapshot.swift in Sources */, + 2E286CDEFC491253FF7615D3 /* NodeListView.swift in Sources */, + 91E86E700B648483F172FAA5 /* ControlsPage.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B40000000000000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B10000000000000000000009 /* MeshMapperAppIntentsExtension.swift in Sources */, + B1000000000000000000000A /* MeshMapperReadIntents.swift in Sources */, + B1000000000000000000000B /* MeshMapperSiriSnapshot.swift in Sources */, + B1000000000000000000000C /* MeshMapperSiriSnapshotStore.swift in Sources */, + B1000000000000000000000D /* RepeaterEntity.swift in Sources */, + B1000000000000000000000E /* HeardRepeaterEntity.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -407,6 +822,22 @@ target = 97C146ED1CF9000F007C117D /* Runner */; targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; }; + A70000000000000000000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A50000000000000000000001 /* MeshMapperLiveActivityExtension */; + targetProxy = A60000000000000000000001 /* PBXContainerItemProxy */; + }; + B70000000000000000000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = B50000000000000000000001 /* MeshMapperAppIntents */; + targetProxy = B60000000000000000000001 /* PBXContainerItemProxy */; + }; + F203972D6336527AC5F4F89D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = MeshMapperWatch; + target = 26F4F6F7B0F55BC605124558 /* MeshMapperWatch */; + targetProxy = 964501C97A62900A93BB2E74 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -462,7 +893,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = DQC6TNKG5P; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -475,6 +906,8 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MESHMAPPER_BUNDLE_PREFIX = net.meshmapper.app; + MESHMAPPER_DEVELOPMENT_TEAM = DQC6TNKG5P; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -490,6 +923,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -497,7 +931,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX)"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -515,7 +949,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX)"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -534,7 +968,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX)"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -551,13 +985,43 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX)"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; }; name = Profile; }; + 705BC20CAD6B880CACD9EA74 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A20000000000000000000008 /* Watch.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MeshMapperWatch/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX).watchkitapp"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + VALIDATE_PRODUCT = YES; + WATCHOS_DEPLOYMENT_TARGET = 11.0; + }; + name = Profile; + }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -591,7 +1055,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = DQC6TNKG5P; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -610,6 +1074,8 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MESHMAPPER_BUNDLE_PREFIX = net.meshmapper.app; + MESHMAPPER_DEVELOPMENT_TEAM = DQC6TNKG5P; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -651,7 +1117,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = DQC6TNKG5P; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -664,6 +1130,8 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MESHMAPPER_BUNDLE_PREFIX = net.meshmapper.app; + MESHMAPPER_DEVELOPMENT_TEAM = DQC6TNKG5P; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -681,6 +1149,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -688,7 +1157,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX)"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -703,6 +1172,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -710,7 +1180,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX)"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -718,6 +1188,243 @@ }; name = Release; }; + A64C5101607B9C9371C99AB5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A20000000000000000000008 /* Watch.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MeshMapperWatch/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX).watchkitapp"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + VALIDATE_PRODUCT = YES; + WATCHOS_DEPLOYMENT_TARGET = 11.0; + }; + name = Release; + }; + A80000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A20000000000000000000007 /* LiveActivity.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MeshMapperLiveActivity/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX).liveactivity"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A80000000000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A20000000000000000000007 /* LiveActivity.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MeshMapperLiveActivity/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX).liveactivity"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + A80000000000000000000003 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A20000000000000000000007 /* LiveActivity.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MeshMapperLiveActivity/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX).liveactivity"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Profile; + }; + FBECC62B604ABFF4A75248F1 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A20000000000000000000008 /* Watch.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MeshMapperWatch/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX).watchkitapp"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 11.0; + }; + name = Debug; + }; + B80000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A20000000000000000000007 /* LiveActivity.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = MeshMapperAppIntents/MeshMapperAppIntents.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MeshMapperAppIntents/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX).appintents"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + B80000000000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A20000000000000000000007 /* LiveActivity.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = MeshMapperAppIntents/MeshMapperAppIntents.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MeshMapperAppIntents/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX).appintents"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + B80000000000000000000003 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A20000000000000000000007 /* LiveActivity.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = MeshMapperAppIntents/MeshMapperAppIntents.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MeshMapperAppIntents/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX).appintents"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Profile; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -751,7 +1458,51 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A90000000000000000000001 /* Build configuration list for PBXNativeTarget "MeshMapperLiveActivityExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A80000000000000000000001 /* Debug */, + A80000000000000000000002 /* Release */, + A80000000000000000000003 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + B90000000000000000000001 /* Build configuration list for PBXNativeTarget "MeshMapperAppIntents" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B80000000000000000000001 /* Debug */, + B80000000000000000000002 /* Release */, + B80000000000000000000003 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E32607468DAE38F2F71EADD5 /* Build configuration list for PBXNativeTarget "MeshMapperWatch" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A64C5101607B9C9371C99AB5 /* Release */, + FBECC62B604ABFF4A75248F1 /* Debug */, + 705BC20CAD6B880CACD9EA74 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "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/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..a28342ff --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,68 @@ +{ + "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" : "maplibre-gl-native-distribution", + "kind" : "remoteSourceControl", + "location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git", + "state" : { + "revision" : "1051e9dfa3546bece1a6eaf33a5ac85ac35d6bda", + "version" : "6.19.1" + } + }, + { + "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 index e3773d42..95d6e55f 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,10 +1,28 @@ + version = "1.7"> + + + + + + + + + + = [ + "tiles.openfreemap.org", + "server.arcgisonline.com", + ] + + static let blockedHostSuffixes: [String] = [ + ".meshmapper.net", + ] + + override class func canInit(with request: URLRequest) -> Bool { + guard let host = request.url?.host else { return false } + if blockedHosts.contains(host) { return true } + return blockedHostSuffixes.contains { host.hasSuffix($0) } + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + + override func startLoading() { + let error = NSError( + domain: NSURLErrorDomain, + code: NSURLErrorNotConnectedToInternet, + userInfo: [NSLocalizedDescriptionKey: "Offline map mode active"] + ) + client?.urlProtocol(self, didFailWithError: error) + } + + override func stopLoading() {} +} + +class IOSMapOfflineBridge { + private var registered = false + + func setOffline(_ offline: Bool) { + if offline, !registered { + URLProtocol.registerClass(TileBlockingURLProtocol.self) + registered = true + } else if !offline, registered { + URLProtocol.unregisterClass(TileBlockingURLProtocol.self) + registered = false + } + } +} + @main @objc class AppDelegate: FlutterAppDelegate { + private let mapOfflineBridge = IOSMapOfflineBridge() + private let liveActivityManager = LiveActivityManager() + private let watchSessionManager = WatchSessionManager() + private let siriIntentCoordinator = SiriIntentCoordinator.shared + private let siriSnapshotStore = MeshMapperSiriSnapshotStore.shared + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -16,9 +85,190 @@ import flutter_background_service_ios GeneratedPluginRegistrant.register(with: self) + // App Shortcut metadata is extracted at build time, but asking the system + // to refresh parameters on launch makes newly added Siri phrases and enum + // synonyms available promptly after an app update. + if #available(iOS 26.0, *) { + MeshMapperAppShortcuts.updateAppShortcutParameters() + } + // Register background service SwiftFlutterBackgroundServicePlugin.taskIdentifier = "net.meshmapper.app.background" + if let controller = window?.rootViewController as? FlutterViewController { + // Method channel: iOS map offline mode bridge. Dart calls + // `setOffline` from map_widget.dart to toggle the URLProtocol + // interceptor that forces tile requests to fail fast, letting + // MapLibre-iOS serve only cached/downloaded tiles. + let offlineChannel = FlutterMethodChannel( + name: "meshmapper/ios_map_offline", + binaryMessenger: controller.binaryMessenger + ) + offlineChannel.setMethodCallHandler { [weak self] call, result in + guard let self = self else { + result(FlutterError(code: "unavailable", message: "bridge deallocated", details: nil)) + return + } + switch call.method { + case "setOffline": + let args = call.arguments as? [String: Any] + let offline = args?["offline"] as? Bool ?? false + self.mapOfflineBridge.setOffline(offline) + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + + // Method channel: local ActivityKit status for active wardriving + // sessions. The widget extension renders the Lock Screen, Dynamic Island, + // and compact system/CarPlay presentations from these snapshots. + let liveActivityChannel = FlutterMethodChannel( + name: "meshmapper/live_activity", + binaryMessenger: controller.binaryMessenger + ) + liveActivityChannel.setMethodCallHandler { [weak self] call, result in + guard let self = self else { + result(FlutterError(code: "unavailable", message: "bridge deallocated", details: nil)) + return + } + self.liveActivityManager.handle(call, result: result) + } + + // Method channel: watchOS companion. Dart pushes WatchSnapshots down + // and the watch sends start/stop/manual-ping intents back up the same + // channel. Unlike the Live Activity, this needs no entitlement. + let watchChannel = FlutterMethodChannel( + name: "meshmapper/watch", + binaryMessenger: controller.binaryMessenger + ) + watchChannel.setMethodCallHandler { [weak self] call, result in + guard let self = self else { + result(FlutterError(code: "unavailable", message: "bridge deallocated", details: nil)) + return + } + self.watchSessionManager.handle(call, result: result) + } + watchSessionManager.attach(channel: watchChannel) + + // Method channel: App Intents. Mutation intents invoke Dart through this + // channel; Dart publishes a separate App Group snapshot for read-only + // intents that must not launch Flutter. + let appIntentChannel = FlutterMethodChannel( + name: "meshmapper/app_intents", + binaryMessenger: controller.binaryMessenger + ) + appIntentChannel.setMethodCallHandler { [weak self] call, result in + guard let self = self else { + result(FlutterError(code: "unavailable", message: "bridge deallocated", details: nil)) + return + } + switch call.method { + case "publishSnapshot": + do { + try self.siriSnapshotStore.writeFlutterPayload(call.arguments) + result(nil) + } catch { + result(FlutterError( + code: "snapshot_write_failed", + message: error.localizedDescription, + details: nil + )) + } + default: + result(FlutterMethodNotImplemented) + } + } + siriIntentCoordinator.attach(channel: appIntentChannel) + + // Method channel: MapLibre tile cache management. Mirrors the Android + // handler in MainActivity.kt. Dart's TileCacheService calls into these + // from the Offline Maps screen's Tile Cache card. + let tileCacheChannel = FlutterMethodChannel( + name: "meshmapper/tile_cache", + binaryMessenger: controller.binaryMessenger + ) + tileCacheChannel.setMethodCallHandler { call, result in + switch call.method { + case "getCacheSize": + result(AppDelegate.mapCacheSizeBytes()) + case "getRegionSizes": + AppDelegate.regionSizes(result: result) + case "clearAmbientCache": + MLNOfflineStorage.shared.clearAmbientCache { error in + if let error = error { + result(FlutterError( + code: "clear_failed", + message: error.localizedDescription, + details: nil)) + } else { + result(nil) + } + } + case "invalidateAmbientCache": + MLNOfflineStorage.shared.invalidateAmbientCache { error in + if let error = error { + result(FlutterError( + code: "invalidate_failed", + message: error.localizedDescription, + details: nil)) + } else { + result(nil) + } + } + default: + result(FlutterMethodNotImplemented) + } + } + } + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + /// Per-region downloaded byte counts, keyed by the Dart-side region ID that + /// maplibre_gl embeds in each pack's context JSON (`{"id": Int, "metadata": …}` + /// — see OfflineRegion.swift in the maplibre_gl plugin). Reads + /// `pack.progress.countOfTileBytesCompleted`, which is the byte size of tile + /// resources (style/sprite/glyph resources live outside this number but are + /// included in the overall cache.db file total reported by `getCacheSize`). + /// + /// Returns `{ idInt64 : bytesInt64 }` over the platform channel. Packs whose + /// context can't be decoded are skipped. + private static func regionSizes(result: @escaping FlutterResult) { + // `.packs` is nil until MLNOfflineStorage finishes its initial database + // load. In practice the maplibre_gl plugin forces that load early, but we + // return an empty map rather than failing if we're called first. + guard let packs = MLNOfflineStorage.shared.packs else { + result([Int64: Int64]()) + return + } + var sizes: [Int64: Int64] = [:] + for pack in packs { + guard let ctx = try? JSONSerialization.jsonObject(with: pack.context), + let dict = ctx as? [String: Any], + let id = dict["id"] as? Int else { + continue + } + sizes[Int64(id)] = Int64(pack.progress.countOfTileBytesCompleted) + } + result(sizes) + } + + /// Mirrors `MapLibreMapsPlugin.getTilesUrl()`: the maplibre_gl iOS plugin + /// stores its tile cache at `//.mapbox/cache.db`. + /// Returns 0 if the file doesn't exist. + private static func mapCacheSizeBytes() -> Int64 { + guard let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask).first, + let bundleId = Bundle.main.bundleIdentifier else { + return 0 + } + let cacheUrl = appSupport + .appendingPathComponent(bundleId) + .appendingPathComponent(".mapbox") + .appendingPathComponent("cache.db") + let attrs = try? FileManager.default.attributesOfItem(atPath: cacheUrl.path) + return (attrs?[.size] as? Int64) ?? 0 + } } diff --git a/ios/Runner/AppIntents/ConnectLastMeshMapperCompanionIntent.swift b/ios/Runner/AppIntents/ConnectLastMeshMapperCompanionIntent.swift new file mode 100644 index 00000000..461843ca --- /dev/null +++ b/ios/Runner/AppIntents/ConnectLastMeshMapperCompanionIntent.swift @@ -0,0 +1,35 @@ +import AppIntents + +@available(iOS 26.0, *) +struct ConnectLastMeshMapperCompanionIntent: AppIntent { + static let title: LocalizedStringResource = "Connect MeshMapper to Last Companion" + static let description = IntentDescription( + "Reconnects MeshMapper to the companion it most recently connected to." + ) + static var authenticationPolicy: IntentAuthenticationPolicy { + .requiresAuthentication + } + static var supportedModes: IntentModes { .foreground(.dynamic) } + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + let result = try await SiriIntentCoordinator.shared.execute( + SiriCommand(kind: .connectLastCompanion) + ) + let message = result.message ?? Self.fallback(for: result) + return .result(value: message, dialog: "\(message)") + } + + private static func fallback(for result: SiriCommandResult) -> String { + switch result.disposition { + case .admitted: + return result.success + ? "MeshMapper connected to its last companion." + : "MeshMapper couldn't connect to its last companion." + case .noOp: + return "MeshMapper is already connected to its last companion." + case .refused: + return "MeshMapper couldn't connect to its last companion." + } + } +} diff --git a/ios/Runner/AppIntents/MeshMapperAppShortcuts.swift b/ios/Runner/AppIntents/MeshMapperAppShortcuts.swift new file mode 100644 index 00000000..d399c1f7 --- /dev/null +++ b/ios/Runner/AppIntents/MeshMapperAppShortcuts.swift @@ -0,0 +1,81 @@ +import AppIntents + +/// The app's single `AppShortcutsProvider`. +/// +/// Both the provider and every intent it names must be members of the app +/// target: an `AppShortcutsProvider` inside the App Intents extension is never +/// indexed, so its phrases silently do nothing when spoken. The read intents +/// are therefore compiled into Runner as well as the extension, which is +/// Apple's documented arrangement for an intent that backs an App Shortcut and +/// must also run in an extension. Keep the count at or below ten; exceeding it +/// is a compile error. +@available(iOS 26.0, *) +struct MeshMapperAppShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: ConnectLastMeshMapperCompanionIntent(), + phrases: [ + "Connect \(.applicationName) to its last companion", + "Reconnect \(.applicationName)", + "Connect \(.applicationName)", + "Connect \(.applicationName) to the last device", + "Connect to the last device in \(.applicationName)", + "Reconnect the last device in \(.applicationName)", + ], + shortTitle: "Connect Companion", + systemImageName: "link" + ) + AppShortcut( + intent: StartMeshMapperSessionIntent(), + phrases: [ + "Start \(.applicationName)", + "Start a session in \(.applicationName)", + "Start mapping with \(.applicationName)", + "Start \(\.$mode) in \(.applicationName)", + "Start \(\.$mode) mode in \(.applicationName)", + "Start a \(\.$mode) session in \(.applicationName)", + "Start \(.applicationName) in \(\.$mode) mode", + "Start \(\.$mode) mapping with \(.applicationName)", + ], + shortTitle: "Start Session", + systemImageName: "antenna.radiowaves.left.and.right" + ) + AppShortcut( + intent: StopMeshMapperSessionIntent(), + phrases: [ + "Stop \(.applicationName)", + "Stop the session in \(.applicationName)", + "Stop mapping with \(.applicationName)", + "End the session in \(.applicationName)", + ], + shortTitle: "Stop Session", + systemImageName: "stop.fill" + ) + AppShortcut( + intent: GetMeshMapperStatusIntent(), + phrases: [ + "What is \(.applicationName) doing", + "Get \(.applicationName) status", + ], + shortTitle: "Session Status", + systemImageName: "waveform" + ) + AppShortcut( + intent: GetRecentlyHeardRepeatersIntent(), + phrases: [ + "What has \(.applicationName) heard", + "Recent repeaters in \(.applicationName)", + ], + shortTitle: "Recent Repeaters", + systemImageName: "dot.radiowaves.left.and.right" + ) + // TODO: Re-register FindMeshMapperRepeaterIntent once spoken repeater + // lookup works. The intent, RepeaterEntity and RepeaterEntityQuery are all + // still built and shipped; only the spoken phrase is withdrawn, because a + // registered phrase that fails is worse than one that was never offered. + // Restore the AppShortcut here, and its bullet in DEVELOPMENT.md's phrase + // list, in the same change. + } + + static var shortcutTileColor: ShortcutTileColor { .teal } +} diff --git a/ios/Runner/AppIntents/MeshMapperSessionMode.swift b/ios/Runner/AppIntents/MeshMapperSessionMode.swift new file mode 100644 index 00000000..e25270c8 --- /dev/null +++ b/ios/Runner/AppIntents/MeshMapperSessionMode.swift @@ -0,0 +1,30 @@ +import AppIntents + +@available(iOS 26.0, *) +enum MeshMapperSessionMode: String, AppEnum { + case passive + case active + case hybrid + + static var typeDisplayRepresentation = TypeDisplayRepresentation( + name: "MeshMapper Mode" + ) + static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [ + .passive: DisplayRepresentation( + title: "Passive Discovery", + synonyms: ["Passive", "Listen Only", "Receive Only"] + ), + .active: DisplayRepresentation( + title: "Active", + synonyms: ["Active Mapping"] + ), + .hybrid: DisplayRepresentation( + title: "Hybrid", + synonyms: ["Hybrid Mapping"] + ), + ] + + var displayName: String { + self == .passive ? "Passive Discovery" : rawValue.capitalized + } +} diff --git a/ios/Runner/AppIntents/SendManualPingIntent.swift b/ios/Runner/AppIntents/SendManualPingIntent.swift new file mode 100644 index 00000000..34ae3e09 --- /dev/null +++ b/ios/Runner/AppIntents/SendManualPingIntent.swift @@ -0,0 +1,24 @@ +import AppIntents + +@available(iOS 26.0, *) +struct SendManualPingIntent: AppIntent { + static let title: LocalizedStringResource = "Send MeshMapper Ping" + static var authenticationPolicy: IntentAuthenticationPolicy { + .requiresAuthentication + } + static var supportedModes: IntentModes { .foreground(.dynamic) } + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + try await requestConfirmation( + conditions: [], + actionName: .send, + dialog: "Send a MeshMapper radio ping?" + ) + let result = try await SiriIntentCoordinator.shared.execute( + SiriCommand(kind: .manualPing) + ) + let message = result.message ?? "MeshMapper couldn't send a ping." + return .result(value: message, dialog: "\(message)") + } +} diff --git a/ios/Runner/AppIntents/SiriIntentCoordinator.swift b/ios/Runner/AppIntents/SiriIntentCoordinator.swift new file mode 100644 index 00000000..b21c6a38 --- /dev/null +++ b/ios/Runner/AppIntents/SiriIntentCoordinator.swift @@ -0,0 +1,184 @@ +import AppIntents +import Flutter +import Foundation + +enum SiriBridgeError: LocalizedError { + case appUnavailable + case timedOut(SiriCommand.Kind) + case invalidResponse + case flutter(String) + + /// The single source for what the person is told, spoken and written alike. + var message: String { + switch self { + case .appUnavailable: + return "MeshMapper did not finish launching in time." + case .timedOut(let kind): + return kind.timeoutMessage + case .invalidResponse: + return "MeshMapper returned an invalid command result." + case .flutter(let message): + return message + } + } + + var errorDescription: String? { message } +} + +/// App Intents dialogs read `localizedStringResource`, not `errorDescription`. +/// Without this conformance every error thrown out of `perform()` reaches the +/// person as a generic system failure dialog instead of the reason above. +@available(iOS 16.0, *) +extension SiriBridgeError: CustomLocalizedStringResourceConvertible { + var localizedStringResource: LocalizedStringResource { + LocalizedStringResource(stringLiteral: message) + } +} + +struct SiriCommand { + enum Kind: String { + case startSession + case stopSession + case manualPing + case connectLastCompanion + + /// What to say when the intent stops waiting for this kind. + /// + /// Only say "cancelled" where it is true. Start and Manual Ping are: Dart + /// holds the same deadline and checks it immediately before every RF send, + /// so nothing goes out and no session survives. + /// + /// The other two are best-effort by design and must not claim otherwise. + /// Connecting cannot be called back once a transport is dialling, and a BLE + /// GATT phase alone may run 15 seconds before protocol setup and + /// authentication. Stopping is deliberately exempt from the deadline + /// altogether (a safe-direction command must never be abandoned because a + /// voice request timed out), and its teardown may queue a pending disable + /// behind an RX window. Both overrun as a matter of course rather than + /// exceptionally, so claiming cancellation would be a lie about as often as + /// it was true. + var timeoutMessage: String { + switch self { + case .startSession, .manualPing: + return "MeshMapper took too long, so the request was cancelled." + case .connectLastCompanion: + return "MeshMapper didn't answer in time, and may still be connecting. Check the app in a moment." + case .stopSession: + return "MeshMapper didn't answer in time, and may still be stopping. Check the app in a moment." + } + } + } + + let id = UUID().uuidString + let kind: Kind + let mode: String? + let sessionId: String? + let issuedAt = Date() + + init(kind: Kind, mode: String? = nil, sessionId: String? = nil) { + self.kind = kind + self.mode = mode + self.sessionId = sessionId + } + + /// How long the intent waits for the real outcome before telling the person + /// it failed. The same instant is handed to Dart as `expiresAtMs`, so giving + /// up here and giving up there are one decision rather than two, for the + /// session kinds. For `connectLastCompanion` it bounds the *response* only; + /// see `Kind.timeoutMessage` for why, and for what is said instead. + var responseTimeout: TimeInterval { + kind == .connectLastCompanion ? 30 : 10 + } + + func dictionary(expiresAt: Date) -> [String: Any?] { + [ + "id": id, + "source": "siri", + "kind": kind.rawValue, + "issuedAtMs": Int64(issuedAt.timeIntervalSince1970 * 1_000), + "expiresAtMs": Int64(expiresAt.timeIntervalSince1970 * 1_000), + "mode": mode, + "sessionId": sessionId, + ] + } +} + +struct SiriCommandResult { + enum Disposition: String { + case admitted + case noOp + case refused + } + + let success: Bool + let disposition: Disposition + let message: String? + let sessionId: String? + let mode: String? + + init(response: Any?) throws { + if let error = response as? FlutterError { + throw SiriBridgeError.flutter(error.message ?? error.code) + } + guard let response = response as? [String: Any], + let success = response["success"] as? Bool, + let rawDisposition = response["disposition"] as? String, + let disposition = Disposition(rawValue: rawDisposition) + else { + throw SiriBridgeError.invalidResponse + } + self.success = success + self.disposition = disposition + message = response["message"] as? String + sessionId = response["sessionId"] as? String + mode = response["mode"] as? String + } +} + +@MainActor +final class SiriIntentCoordinator { + static let shared = SiriIntentCoordinator() + + private var channel: FlutterMethodChannel? + private init() {} + + func attach(channel: FlutterMethodChannel) { + self.channel = channel + } + + func execute(_ command: SiriCommand) async throws -> SiriCommandResult { + let deadline = Date().addingTimeInterval(10) + while channel == nil, Date() < deadline { + try Task.checkCancellation() + try await Task.sleep(nanoseconds: 100_000_000) + } + guard let channel else { throw SiriBridgeError.appUnavailable } + + return try await withCheckedThrowingContinuation { continuation in + var completed = false + // Measured from here, not from the command's creation, because the wait + // for the channel above can consume most of a cold launch on its own. + let responseTimeout = command.responseTimeout + let expiresAt = Date().addingTimeInterval(responseTimeout) + let timeout = DispatchWorkItem { + guard !completed else { return } + completed = true + continuation.resume(throwing: SiriBridgeError.timedOut(command.kind)) + } + DispatchQueue.main.asyncAfter(deadline: .now() + responseTimeout, execute: timeout) + channel.invokeMethod( + "command", + arguments: command.dictionary(expiresAt: expiresAt) + ) { response in + guard !completed else { return } + completed = true + timeout.cancel() + do { + continuation.resume(returning: try SiriCommandResult(response: response)) + } catch { + continuation.resume(throwing: error) + } + } + } + } +} diff --git a/ios/Runner/AppIntents/StartMeshMapperSessionIntent.swift b/ios/Runner/AppIntents/StartMeshMapperSessionIntent.swift new file mode 100644 index 00000000..080ba1b3 --- /dev/null +++ b/ios/Runner/AppIntents/StartMeshMapperSessionIntent.swift @@ -0,0 +1,44 @@ +import AppIntents + +@available(iOS 26.0, *) +struct StartMeshMapperSessionIntent: AppIntent { + static let title: LocalizedStringResource = "Start MeshMapper Session" + static let description = IntentDescription( + "Starts Active, Passive Discovery, or Hybrid mapping using MeshMapper's current radio policy." + ) + static var authenticationPolicy: IntentAuthenticationPolicy { + .requiresAuthentication + } + static var supportedModes: IntentModes { .foreground(.dynamic) } + + @Parameter(title: "Mode", default: .passive) + var mode: MeshMapperSessionMode + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + let result = try await SiriIntentCoordinator.shared.execute( + SiriCommand(kind: .startSession, mode: mode.rawValue) + ) + let message = result.message ?? Self.fallback(for: result, mode: mode) + return .result( + value: message, + dialog: "\(message)" + ) + } + + private static func fallback( + for result: SiriCommandResult, + mode: MeshMapperSessionMode + ) -> String { + switch result.disposition { + case .admitted: + return result.success + ? "MeshMapper started in \(mode.displayName) mode." + : "MeshMapper couldn't start." + case .noOp: + return "MeshMapper is already running." + case .refused: + return "MeshMapper couldn't start." + } + } +} diff --git a/ios/Runner/AppIntents/StopMeshMapperSessionIntent.swift b/ios/Runner/AppIntents/StopMeshMapperSessionIntent.swift new file mode 100644 index 00000000..9e9efdf4 --- /dev/null +++ b/ios/Runner/AppIntents/StopMeshMapperSessionIntent.swift @@ -0,0 +1,32 @@ +import AppIntents + +@available(iOS 26.0, *) +struct StopMeshMapperSessionIntent: AppIntent { + static let title: LocalizedStringResource = "Stop MeshMapper Session" + // Stopping is the safe direction: it only ends transmissions, exposes + // nothing, and must stay reachable from a locked phone mid-drive. + static var authenticationPolicy: IntentAuthenticationPolicy { .alwaysAllowed } + static var supportedModes: IntentModes { + [.background, .foreground(.dynamic)] + } + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + // The snapshot is an optimization, never authority for the safe-direction + // Stop command. A missing, stale, corrupt, or newer snapshot must not keep + // a live radio session running. Bind to an ID only when the cache says a + // session exists; otherwise let Dart resolve the current session. + let snapshot = try? MeshMapperSiriSnapshotStore.shared.read() + let sessionId: String? + if let snapshot, snapshot.session.active || snapshot.session.starting { + sessionId = snapshot.session.id + } else { + sessionId = nil + } + let result = try await SiriIntentCoordinator.shared.execute( + SiriCommand(kind: .stopSession, sessionId: sessionId) + ) + let message = result.message ?? "MeshMapper couldn't stop." + return .result(value: message, dialog: "\(message)") + } +} diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index bdea0622..8aae773a 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -32,6 +32,8 @@ NSBluetoothAlwaysUsageDescription This app needs Bluetooth to connect to MeshCore devices for wardriving + NSCameraUsageDescription + This app does not use the camera. This entry is required by a file picker library. NSBluetoothPeripheralUsageDescription This app needs Bluetooth to connect to MeshCore devices for wardriving NSLocationAlwaysAndWhenInUseUsageDescription @@ -154,5 +156,20 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + NSSupportsLiveActivities + + CFBundleURLTypes + + + CFBundleURLName + net.meshmapper.app.auth + CFBundleTypeRole + Editor + CFBundleURLSchemes + + meshmapper-auth + + + diff --git a/ios/Runner/LiveActivityManager.swift b/ios/Runner/LiveActivityManager.swift new file mode 100644 index 00000000..9d807e73 --- /dev/null +++ b/ios/Runner/LiveActivityManager.swift @@ -0,0 +1,322 @@ +import ActivityKit +import Flutter +import Foundation + +/// Native ActivityKit endpoint for the Flutter method channel. +/// +/// Flutter sends complete snapshots. The manager keeps at most one activity, +/// updates it serially, and doesn't recreate a Live Activity the user dismissed +/// during the same wardriving session. +/// Sentinels shared with the Dart side of the channel. +enum MeshMapperLiveActivityStatus { + /// This OS cannot host a Live Activity at all, at any point in this session. + static let unsupported = "unsupported" +} + +final class LiveActivityManager { + private enum BridgeError: LocalizedError { + case invalidArguments + + var errorDescription: String? { + "The Live Activity payload was invalid." + } + } + + private var requestedSessionID: String? + + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "sync": + guard let payload = call.arguments as? [String: Any] else { + result(flutterError(BridgeError.invalidArguments, code: "invalid_arguments")) + return + } + // Distinct from `false`, which means "not right now" and earns a retry. + // This host will never gain ActivityKit, so a retry loop for the life of + // every session is pure waste; Dart stops asking for good. + guard #available(iOS 16.2, *) else { + result(MeshMapperLiveActivityStatus.unsupported) + return + } + // Genuinely "not right now": the wearer can enable Live Activities in + // Settings while a session is running, so this one keeps its backoff. + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + result(false) + return + } + + Task { @MainActor in + do { + let parsed = try parse(payload) + try await sync(attributes: parsed.attributes, state: parsed.state) + result(true) + } catch ActivityAuthorizationError.visibility { + // ActivityKit only permits `request` from a foreground app, and a + // session can begin backgrounded — auto-ping is restored after a BLE + // auto-reconnect under the background service. Reported as an error + // this bypassed Dart's 30 s backoff, which only arms on a `false` + // result, so every throttled sync retried and logged a failure for as + // long as the app stayed in the background. `false` is the honest + // answer anyway: there is nowhere to put an activity right now. + NSLog("[LIVE ACTIVITY] Cannot start while backgrounded; will retry") + result(false) + } catch { + result(self.flutterError(error, code: "sync_failed")) + } + } + + case "end": + guard #available(iOS 16.2, *) else { + result(nil) + return + } + let immediate = + (call.arguments as? [String: Any])?["immediate"] as? Bool ?? false + Task { @MainActor in + await endAll(keepSummaryFor: immediate ? 0 : 60) + requestedSessionID = nil + result(nil) + } + + default: + result(FlutterMethodNotImplemented) + } + } + + private func flutterError(_ error: Error, code: String) -> FlutterError { + FlutterError( + code: code, + message: error.localizedDescription, + details: String(describing: error) + ) + } + + @available(iOS 16.2, *) + private func parse( + _ payload: [String: Any] + ) throws -> ( + attributes: MeshMapperActivityAttributes, + state: MeshMapperActivityAttributes.ContentState + ) { + guard let sessionID = boundedString(payload["sessionId"], maxLength: 64), + let mode = boundedString(payload["mode"], maxLength: 16), + let phase = boundedString(payload["phase"], maxLength: 32), + let phaseTitle = boundedString(payload["phaseTitle"], maxLength: 48), + let updatedAt = date(payload["updatedAt"]) + else { + throw BridgeError.invalidArguments + } + + let repeaterPayloads = payload["repeaters"] as? [Any] ?? [] + // Four, because the watch Smart Stack card draws them two columns by two + // rows. The lock screen and the island still take fewer. + let repeaters = repeaterPayloads.prefix(4).compactMap { + rawItem -> MeshMapperActivityAttributes.HeardRepeater? in + guard let item = rawItem as? [String: Any], + let id = boundedString(item["id"], maxLength: 16), + let snr = finiteNumber(item["snr"]) + else { + return nil + } + return MeshMapperActivityAttributes.HeardRepeater( + id: id, + name: boundedString(item["name"], maxLength: 36), + snr: min(max(snr, -200), 200), + typeColor: resolvedColor(item["typeColor"]), + snrColor: resolvedColor(item["snrColor"]) + ) + } + + let attributes = MeshMapperActivityAttributes(sessionID: sessionID) + let state = MeshMapperActivityAttributes.ContentState( + mode: mode, + phase: phase, + phaseTitle: phaseTitle, + phaseDetail: boundedString(payload["phaseDetail"], maxLength: 80), + phaseEndsAt: date(payload["phaseEndsAt"]), + phaseDurationMs: positiveInteger(payload["phaseDurationMs"]), + pingColor: resolvedColor(payload["pingColor"]), + modeColor: resolvedColor(payload["modeColor"]), + isConnected: payload["isConnected"] as? Bool ?? false, + zoneCode: boundedString(payload["zoneCode"], maxLength: 12), + noiseFloorDbm: boundedInteger(payload["noiseFloorDbm"], min: -200, max: 100), + companionBatteryPct: boundedInteger(payload["companionBatteryPct"], min: 0, max: 100), + showRepeaterNames: payload["showRepeaterNames"] as? Bool, + txCount: nonnegativeInteger(payload["txCount"]), + rxCount: nonnegativeInteger(payload["rxCount"]), + discoveryCount: nonnegativeInteger(payload["discoveryCount"]), + traceCount: nonnegativeInteger(payload["traceCount"]), + queueSize: nonnegativeInteger(payload["queueSize"]), + repeaters: repeaters, + totalHeardCount: max(nonnegativeInteger(payload["totalHeardCount"]), repeaters.count), + repeatersAreCurrent: payload["repeatersAreCurrent"] as? Bool ?? false, + updatedAt: updatedAt + ) + return (attributes, state) + } + + @available(iOS 16.2, *) + @MainActor + private func sync( + attributes: MeshMapperActivityAttributes, + state: MeshMapperActivityAttributes.ContentState + ) async throws { + let activities = Activity.activities + let matching = activities.filter { $0.attributes.sessionID == attributes.sessionID } + let unrelated = activities.filter { $0.attributes.sessionID != attributes.sessionID } + + for activity in unrelated { + await end(activity, keepSummaryFor: 0) + } + + let content = ActivityContent( + state: state, + staleDate: staleDate(for: state) + ) + + if let activity = matching.first { + requestedSessionID = attributes.sessionID + await activity.update(content) + for duplicate in matching.dropFirst() { + await end(duplicate, keepSummaryFor: 0) + } + return + } + + // A missing activity with the same session ID means the user or system + // dismissed it. Don't recreate it until MeshMapper starts a new session. + if requestedSessionID == attributes.sessionID { + return + } + + requestedSessionID = attributes.sessionID + do { + _ = try Activity.request( + attributes: attributes, + content: content, + pushType: nil + ) + } catch { + requestedSessionID = nil + throw error + } + } + + @available(iOS 16.2, *) + private func staleDate( + for state: MeshMapperActivityAttributes.ContentState + ) -> Date { + if let phaseEndsAt = state.phaseEndsAt { + return phaseEndsAt.addingTimeInterval(45) + } + return state.updatedAt.addingTimeInterval(5 * 60) + } + + @available(iOS 16.2, *) + @MainActor + private func endAll(keepSummaryFor seconds: TimeInterval) async { + for activity in Activity.activities { + await end(activity, keepSummaryFor: seconds) + } + } + + @available(iOS 16.2, *) + @MainActor + private func end( + _ activity: Activity, + keepSummaryFor seconds: TimeInterval + ) async { + var finalState = activity.content.state + finalState.phase = "stopped" + finalState.phaseTitle = "Session ended" + finalState.phaseDetail = summary(for: finalState) + finalState.phaseEndsAt = nil + finalState.phaseDurationMs = nil + finalState.repeatersAreCurrent = false + finalState.updatedAt = Date() + + let dismissalPolicy: ActivityUIDismissalPolicy = + seconds > 0 + ? .after(Date().addingTimeInterval(seconds)) + : .immediate + await activity.end( + ActivityContent(state: finalState, staleDate: nil), + dismissalPolicy: dismissalPolicy + ) + } + + @available(iOS 16.2, *) + private func summary( + for state: MeshMapperActivityAttributes.ContentState + ) -> String { + var parts = ["TX \(state.txCount)", "RX \(state.rxCount)"] + if state.discoveryCount > 0 { parts.append("DISC \(state.discoveryCount)") } + if state.traceCount > 0 { parts.append("TRACE \(state.traceCount)") } + return parts.joined(separator: " · ") + } + + private func boundedString(_ value: Any?, maxLength: Int) -> String? { + guard let value = value as? String else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return String(trimmed.prefix(maxLength)) + } + + private func date(_ value: Any?) -> Date? { + guard let milliseconds = finiteNumber(value) else { return nil } + return Date(timeIntervalSince1970: milliseconds / 1000) + } + + private func finiteNumber(_ value: Any?) -> Double? { + let parsed: Double? + if let number = value as? NSNumber { + parsed = number.doubleValue + } else if let value = value as? Double { + parsed = value + } else if let value = value as? Int { + parsed = Double(value) + } else { + parsed = nil + } + guard let parsed, parsed.isFinite else { return nil } + return parsed + } + + private func nonnegativeInteger(_ value: Any?) -> Int { + if let number = value as? NSNumber { return max(number.intValue, 0) } + if let value = value as? Int { return max(value, 0) } + return 0 + } + + private func boundedInteger(_ value: Any?, min minValue: Int, max maxValue: Int) -> Int? { + guard let value = finiteNumber(value) else { return nil } + // Clamp in Double space first: Int(hugeFiniteDouble) is a runtime trap. + let clamped = Swift.min(Swift.max(value, Double(minValue)), Double(maxValue)) + return Int(clamped) + } + + private func positiveInteger(_ value: Any?) -> Int? { + guard let value = finiteNumber(value), value > 0 else { return nil } + return Int(min(value, Double(24 * 60 * 60 * 1000))) + } + + /// Guarded like every other member that names the attributes type: the + /// deployment target predates ActivityKit, so mentioning it unguarded fails + /// to compile even in a helper that never runs on an older OS. + @available(iOS 16.2, *) + private func resolvedColor( + _ value: Any? + ) -> MeshMapperActivityAttributes.ResolvedColor? { + guard let value = value as? [String: Any], + let r = finiteNumber(value["r"]), + let g = finiteNumber(value["g"]), + let b = finiteNumber(value["b"]) + else { return nil } + + return MeshMapperActivityAttributes.ResolvedColor( + r: min(max(r, 0), 1), + g: min(max(g, 0), 1), + b: min(max(b, 0), 1) + ) + } +} diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 00000000..7037779d --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.net.meshmapper.app.shared + + + diff --git a/ios/Runner/WatchSessionManager.swift b/ios/Runner/WatchSessionManager.swift new file mode 100644 index 00000000..8b8714c1 --- /dev/null +++ b/ios/Runner/WatchSessionManager.swift @@ -0,0 +1,304 @@ +import Flutter +import Foundation +import WatchConnectivity + +/// Bridges Flutter app state to the watchOS companion and relays commands back. +/// +/// Two delivery paths, chosen by urgency: +/// +/// - `updateApplicationContext` for the steady state. It coalesces (latest +/// wins) and is delivered even when the watch app is backgrounded or not +/// running, which is what makes the wrist-down case work. +/// - `sendMessage` for phase transitions and ping results, which need to land +/// now. It requires a reachable counterpart, so it always falls back to the +/// application context rather than being the only path. +/// +/// WatchConnectivity needs no entitlement and no capability registration — +/// this whole file works without developer-portal access. +final class WatchSessionManager: NSObject { + private enum BridgeError: LocalizedError { + case invalidArguments + case unsupported + + var errorDescription: String? { + switch self { + case .invalidArguments: return "The watch payload was invalid." + case .unsupported: return "WatchConnectivity is unavailable on this device." + } + } + } + + /// Set by AppDelegate so inbound commands can reach Dart. + /// + /// Held strongly on purpose. `setMethodCallHandler` makes the binary + /// messenger retain the handler *block*, not the channel object, so a + /// channel left in a local goes away when `didFinishLaunchingWithOptions` + /// returns. The other channels in AppDelegate survive that because they + /// only ever receive calls; this one has to invoke Dart from native, so it + /// needs an owner. AppDelegate's handler block captures `self` weakly, so + /// this does not form a cycle. + private var channel: FlutterMethodChannel? + + private var session: WCSession? { + WCSession.isSupported() ? WCSession.default : nil + } + + /// Last context we successfully handed to WatchConnectivity, so a repeated + /// identical payload doesn't churn the radio. + private var lastContextData: Data? + + /// Backoff for retrying a failed activation, doubling to a cap. + /// + /// A failed activation used to be permanent: Dart's `canSync` stays false, so + /// `send(payload:urgent:)` — the only other caller of `activateIfNeeded()` — + /// is never reached, and the diagnostics screen polls `status` without ever + /// asking for another attempt. The watch then stayed unreachable for the life + /// of the process even after whatever caused the failure went away. + private var activationRetryDelay: TimeInterval = 2 + private static let maximumActivationRetryDelay: TimeInterval = 60 + private var activationRetryScheduled = false + + func attach(channel: FlutterMethodChannel) { + self.channel = channel + activateIfNeeded() + } + + private func activateIfNeeded() { + guard let session else { return } + // `.inactive` belongs to `sessionDidDeactivate`, which reactivates on its + // own; activating from here would race it. + if session.activationState == .notActivated { + session.delegate = self + session.activate() + } + } + + private func scheduleActivationRetry() { + guard !activationRetryScheduled, session != nil else { return } + activationRetryScheduled = true + let delay = activationRetryDelay + activationRetryDelay = min(delay * 2, Self.maximumActivationRetryDelay) + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self else { return } + self.activationRetryScheduled = false + self.activateIfNeeded() + } + } + + // MARK: - Flutter → watch + + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "sync": + guard let args = call.arguments as? [String: Any], + let payload = args["payload"] as? [String: Any] + else { + result(flutterError(BridgeError.invalidArguments, code: "invalid_arguments")) + return + } + let urgent = args["urgent"] as? Bool ?? false + result(send(payload: payload, urgent: urgent)) + + case "clear": + lastContextData = nil + result(nil) + + case "status": + // Dart polls this while the diagnostics screen is open, which makes it a + // natural second chance at an activation that failed at launch. + activateIfNeeded() + result(statusDictionary()) + + default: + result(FlutterMethodNotImplemented) + } + } + + /// Returns false when there is nowhere to deliver to — no paired watch, no + /// installed app, or no WatchConnectivity at all. Dart treats that as + /// "don't bother", not as an error. + private func send(payload: [String: Any], urgent: Bool) -> Bool { + guard let session, session.activationState == .activated else { + activateIfNeeded() + return false + } + guard session.isPaired, session.isWatchAppInstalled else { return false } + + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { + return false + } + + // Urgent updates try the immediate path first, but always fall through to + // the application context so a missed message can't strand the watch on + // stale state. + if urgent, session.isReachable { + session.sendMessage( + [MeshMapperWatchWire.payloadKey: data], + replyHandler: nil, + errorHandler: { [weak self] error in + NSLog("[WATCH] sendMessage failed, context still pending: \(error.localizedDescription)") + _ = self + } + ) + } + + guard data != lastContextData else { return true } + + do { + try session.updateApplicationContext([MeshMapperWatchWire.payloadKey: data]) + lastContextData = data + return true + } catch { + NSLog("[WATCH] updateApplicationContext failed: \(error.localizedDescription)") + return false + } + } + + private func statusDictionary() -> [String: Any] { + guard let session else { + return [ + "supported": false, + "paired": false, + "installed": false, + "reachable": false, + "activated": false, + ] + } + return [ + "supported": true, + "paired": session.isPaired, + "installed": session.isWatchAppInstalled, + "reachable": session.isReachable, + "activated": session.activationState == .activated, + ] + } + + /// Dart performs the expensive geo build only while a real destination + /// exists. Push changes as well as answering its startup query: pairing and + /// app installation can happen after Flutter has been alive for hours, and + /// a launch-time false must never become a permanent gate. + /// + /// - Parameter nativeCacheCleared: whether `lastContextData` was dropped + /// alongside this push. Dart mirrors that cache to decide what it can + /// dedupe, so it needs to know which pushes invalidate it and which do not. + /// Only `sessionWatchStateDidChange` clears it here; reachability flips on + /// every wrist raise and lower, and telling Dart to forget everything that + /// often forced a full context resend per glance and voided the map-geo + /// lease. + private func publishStatus(nativeCacheCleared: Bool = false) { + guard let channel else { return } + DispatchQueue.main.async { + var status = self.statusDictionary() + status["nativeCacheCleared"] = nativeCacheCleared + channel.invokeMethod("availabilityChanged", arguments: status) + } + } + + private func flutterError(_ error: Error, code: String) -> FlutterError { + FlutterError( + code: code, + message: error.localizedDescription, + details: String(describing: error) + ) + } + + // MARK: - watch → Flutter + + /// Relays a command to Dart. Dart owns the synchronous admission decision and + /// starts accepted work separately; this side never evaluates whether a + /// transmit is legal or waits for BLE/network completion. + /// + /// The ack is logged rather than returned. Queued delivery has no reply + /// channel to return it on, which is the whole reason the wire carries + /// outcomes as snapshots and one-shot cues instead. + private func relayCommand(_ payload: [String: Any]) { + guard let channel else { + NSLog("[WATCH] Command dropped: no method channel") + return + } + + let kind = payload["kind"] as? String ?? "unknown" + NSLog("[WATCH] Command received: \(kind)") + + // Method channel calls must happen on the main thread; WatchConnectivity + // delivers on a background queue. + DispatchQueue.main.async { + channel.invokeMethod("command", arguments: payload) { response in + if let dict = response as? [String: Any] { + NSLog( + "[WATCH] Command \(kind) acked: accepted=\(dict["accepted"] ?? "?") " + + "reason=\(dict["reason"] ?? "none")" + ) + } else { + NSLog("[WATCH] Command \(kind) got no response from Dart") + } + } + } + } +} + +// MARK: - WCSessionDelegate + +extension WatchSessionManager: WCSessionDelegate { + func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + if let error { + NSLog("[WATCH] Activation failed: \(error.localizedDescription)") + DispatchQueue.main.async { [weak self] in self?.scheduleActivationRetry() } + } else if activationState == .activated { + DispatchQueue.main.async { [weak self] in self?.activationRetryDelay = 2 } + } + publishStatus() + } + + func sessionDidBecomeInactive(_ session: WCSession) {} + + /// Reactivate after a watch switch, otherwise the session stays dead. + func sessionDidDeactivate(_ session: WCSession) { + session.activate() + } + + func sessionWatchStateDidChange(_ session: WCSession) { + // Hopped, not written here. WatchConnectivity delivers this on a background + // thread while `send(payload:urgent:)` reads and writes the same property + // on the platform thread, which is a plain data race on the dedupe cache — + // and losing that race means a snapshot the watch needed gets suppressed as + // "identical" against a value another thread was midway through clearing. + DispatchQueue.main.async { [weak self] in + // A newly installed or newly paired watch has no context yet. + self?.lastContextData = nil + } + publishStatus(nativeCacheCleared: true) + } + + /// The documented signal for reachability changing. Without it, the status + /// this bridge reports to Dart was only as current as the last activation or + /// watch-state change, and the diagnostics screen looked live purely because + /// it polls every five seconds while it is open. + func sessionReachabilityDidChange(_ session: WCSession) { + publishStatus() + } + + func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) { + guard let command = userInfo[MeshMapperWatchWire.commandKey] as? [String: Any] else { + NSLog("[WATCH] Malformed queued command payload: \(Array(userInfo.keys))") + return + } + relayCommand(command) + } + +} + +// The `didReceiveMessage` overloads that used to sit here are gone. They +// existed for watch builds predating the queued transport, and no such build +// has ever shipped — the companion app is landing for the first time with the +// queued path already in place, and `WatchSessionClient.send(_:)` calls +// `transferUserInfo` exclusively. +// +// Removing them takes away a second entry point into transmit admission, which +// is the part worth being strict about: the queued path is deliberate, because +// `sendMessage` can execute a command and still fail its reply as +// undeliverable, leaving the wrist unable to tell a refusal from a lost ack. diff --git a/ios/RunnerTests/AppIntentLogicTests.swift b/ios/RunnerTests/AppIntentLogicTests.swift new file mode 100644 index 00000000..b1190068 --- /dev/null +++ b/ios/RunnerTests/AppIntentLogicTests.swift @@ -0,0 +1,255 @@ +import XCTest + +@testable import Runner + +final class AppIntentLogicTests: XCTestCase { + func testConnectLastCompanionCommandUsesDedicatedWireKind() { + let command = SiriCommand(kind: .connectLastCompanion) + let payload = command.dictionary(expiresAt: Date()) + + XCTAssertEqual(payload["source"] as? String, "siri") + XCTAssertEqual(payload["kind"] as? String, "connectLastCompanion") + XCTAssertNil(payload["mode"] as? String) + XCTAssertNil(payload["sessionId"] as? String) + } + + func testCommandCarriesTheSameDeadlineTheIntentWaitsOn() { + // Dart must be able to stop for the same reason, and at the same instant, + // that the intent stops waiting; otherwise a slow start reaches the radio + // after the person has already been told it failed. + let command = SiriCommand(kind: .startSession, mode: "active") + let expiresAt = Date().addingTimeInterval(command.responseTimeout) + let payload = command.dictionary(expiresAt: expiresAt) + + XCTAssertEqual(command.responseTimeout, 10) + XCTAssertEqual( + payload["expiresAtMs"] as? Int64, + Int64(expiresAt.timeIntervalSince1970 * 1_000) + ) + } + + func testOnlyCancellableKindsClaimCancellation() { + // Dart holds the same deadline for these two and checks it before every RF + // send, so "cancelled" is literally true for them. + for kind in [SiriCommand.Kind.startSession, .manualPing] { + XCTAssertEqual( + SiriBridgeError.timedOut(kind).errorDescription, + "MeshMapper took too long, so the request was cancelled." + ) + } + + // Connect cannot be called back once a transport is dialling, and Stop is + // deliberately exempt from the deadline and may still be tearing down. + // Neither may claim cancellation. + let connect = + SiriBridgeError.timedOut(.connectLastCompanion).errorDescription ?? "" + XCTAssertEqual( + connect, + "MeshMapper didn't answer in time, and may still be connecting. Check the app in a moment." + ) + + let stop = SiriBridgeError.timedOut(.stopSession).errorDescription ?? "" + XCTAssertEqual( + stop, + "MeshMapper didn't answer in time, and may still be stopping. Check the app in a moment." + ) + + XCTAssertFalse(connect.contains("cancelled")) + XCTAssertFalse(stop.contains("cancelled")) + } + + func testConnectAllowsLongerThanTheOtherMutations() { + XCTAssertEqual(SiriCommand(kind: .connectLastCompanion).responseTimeout, 30) + XCTAssertEqual(SiriCommand(kind: .stopSession).responseTimeout, 10) + XCTAssertEqual(SiriCommand(kind: .manualPing).responseTimeout, 10) + } + + func testDecodesVersionOneSnapshotWithoutUserCoordinates() throws { + let json = """ + { + "version": 1, + "updatedAtMs": 1787529600000, + "connection": { + "isConnected": true, + "deviceName": "MeshCore", + "batteryPercent": 83, + "gpsStatus": "fresh" + }, + "session": { + "id": "session-1", + "startedAtMs": 1787529000000, + "active": true, + "starting": false, + "mode": "passive", + "phase": "listening", + "phaseTitle": "Listening", + "phaseDetail": null, + "phaseEndsAtMs": null, + "zoneCode": "US-CA", + "txCount": 0, + "rxCount": 1, + "discoveryCount": 0, + "traceCount": 0, + "queueSize": 0, + "uniqueRepeatersHeard": 1 + }, + "controls": { + "availableStartModes": ["active", "passive", "hybrid"], + "canStart": false, + "startBlockedReason": "A session is already running.", + "canStop": true, + "canManualPing": false, + "manualPingBlockedReason": "Manual ping is unavailable in passive mode.", + "manualCooldownEndsAtMs": null + }, + "recentHeard": [{ + "entityId": "repeater-1", + "displayHexId": "A1B2C3D4", + "name": "Ridge", + "observedAtMs": 1787529590000, + "kind": "passiveRx", + "direct": true, + "hopCount": 0, + "snr": 7.5, + "rssi": -91, + "distanceM": 1200.0, + "repeaterLat": 37.1, + "repeaterLon": -122.1, + "resolved": true + }], + "repeaters": [{ + "id": "repeater-1", + "name": "Ridge", + "hexId": "A1B2C3D4", + "zoneCode": "US-CA", + "isActive": true, + "isNew": false, + "serverLastHeardMs": 1787529590000, + "latitude": 37.1, + "longitude": -122.1 + }] + } + """ + + let snapshot = try JSONDecoder().decode( + MeshMapperSiriSnapshot.self, + from: XCTUnwrap(json.data(using: .utf8)) + ) + + XCTAssertEqual(snapshot.version, MeshMapperSiriSnapshotStore.supportedVersion) + XCTAssertEqual(snapshot.session.id, "session-1") + XCTAssertEqual( + snapshot.session.startedAt, + Date(timeIntervalSince1970: 1_787_529_000) + ) + XCTAssertEqual(snapshot.recentHeard.first?.entityId, "repeater-1") + XCTAssertEqual(snapshot.recentHeard.first?.repeaterLat, 37.1) + XCTAssertEqual( + snapshot.recentHeard.first?.stableEntityIdentifier, + "repeater-1|1787529590000|passiveRx|direct|0" + ) + XCTAssertFalse(json.contains("userLat")) + XCTAssertFalse(json.contains("userLon")) + } + + func testSnapshotFromAnOlderBuildStillDecodesWithoutASessionStart() throws { + // startedAtMs is additive within version 1, so an extension updated ahead + // of the app must keep reading the file the old app left behind. + let json = """ + { + "version": 1, + "updatedAtMs": 1787529600000, + "connection": { + "isConnected": false, + "deviceName": null, + "batteryPercent": null, + "gpsStatus": "unavailable" + }, + "session": { + "id": null, + "active": false, + "starting": false, + "mode": "idle", + "phase": "idle", + "phaseTitle": "Idle", + "phaseDetail": null, + "phaseEndsAtMs": null, + "zoneCode": null, + "txCount": 0, + "rxCount": 0, + "discoveryCount": 0, + "traceCount": 0, + "queueSize": 0, + "uniqueRepeatersHeard": 0 + }, + "controls": { + "availableStartModes": [], + "canStart": false, + "startBlockedReason": "Connect first.", + "canStop": false, + "canManualPing": false, + "manualPingBlockedReason": "Connect first.", + "manualCooldownEndsAtMs": null + }, + "recentHeard": [], + "repeaters": [] + } + """ + + let snapshot = try JSONDecoder().decode( + MeshMapperSiriSnapshot.self, + from: XCTUnwrap(json.data(using: .utf8)) + ) + + XCTAssertNil(snapshot.session.startedAtMs) + XCTAssertNil(snapshot.session.startedAt) + } + + func testRejectsUnsupportedSnapshotVersionBeforeWriting() { + let snapshot = MeshMapperSiriSnapshot( + version: 2, + updatedAtMs: 0, + connection: MeshMapperSiriConnection( + isConnected: false, + deviceName: nil, + batteryPercent: nil, + gpsStatus: "unavailable" + ), + session: MeshMapperSiriSession( + id: nil, + startedAtMs: nil, + active: false, + starting: false, + mode: "none", + phase: "idle", + phaseTitle: "Idle", + phaseDetail: nil, + phaseEndsAtMs: nil, + zoneCode: nil, + txCount: 0, + rxCount: 0, + discoveryCount: 0, + traceCount: 0, + queueSize: 0, + uniqueRepeatersHeard: 0 + ), + controls: MeshMapperSiriControls( + availableStartModes: ["active", "passive", "hybrid"], + canStart: false, + startBlockedReason: "Connect first.", + canStop: false, + canManualPing: false, + manualPingBlockedReason: "Connect first.", + manualCooldownEndsAtMs: nil + ), + recentHeard: [], + repeaters: [] + ) + + XCTAssertThrowsError(try MeshMapperSiriSnapshotStore().write(snapshot)) { error in + guard case MeshMapperSiriSnapshotStoreError.unsupportedVersion(2) = error else { + return XCTFail("Expected unsupportedVersion(2), got \(error)") + } + } + } +} diff --git a/ios/Shared/AppIntents/HeardRepeaterEntity.swift b/ios/Shared/AppIntents/HeardRepeaterEntity.swift new file mode 100644 index 00000000..be4e28ba --- /dev/null +++ b/ios/Shared/AppIntents/HeardRepeaterEntity.swift @@ -0,0 +1,75 @@ +import AppIntents +import Foundation + +@available(iOS 26.0, *) +struct HeardRepeaterEntity: AppEntity { + static var typeDisplayRepresentation = TypeDisplayRepresentation( + name: "Heard MeshMapper Repeater", + numericFormat: "\(placeholder: .int) heard repeaters" + ) + static var defaultQuery = HeardRepeaterEntityQuery() + + let id: String + let repeater: RepeaterEntity? + let displayHexId: String + let observedAtMs: Int64 + let snr: Double? + let distanceM: Double? + let kind: String + let direct: Bool + + init( + observation: MeshMapperSiriObservation, + repeaterById: [String: MeshMapperSiriRepeater] + ) { + // Derived only from observation content so sorting, filtering, or limiting + // cannot change the ID a saved Shortcut later asks EntityQuery to resolve. + id = observation.stableEntityIdentifier + if let entityId = observation.entityId, + let repeaterSnapshot = repeaterById[entityId] { + repeater = RepeaterEntity(snapshot: repeaterSnapshot) + } else { + repeater = nil + } + displayHexId = observation.displayHexId + observedAtMs = observation.observedAtMs + snr = observation.snr + distanceM = observation.distanceM + kind = observation.kind + direct = observation.direct + } + + var displayRepresentation: DisplayRepresentation { + let title = repeater?.name ?? "Repeater \(displayHexId)" + let subtitle = snr.map { String(format: "%.1f dB", $0) } + return DisplayRepresentation( + title: "\(title)", + subtitle: subtitle.map { "\($0)" } + ) + } +} + +@available(iOS 26.0, *) +struct HeardRepeaterEntityQuery: EntityQuery { + func entities(for identifiers: [HeardRepeaterEntity.ID]) async throws -> [HeardRepeaterEntity] { + let wanted = Set(identifiers) + return try observations().filter { wanted.contains($0.id) } + } + + func suggestedEntities() async throws -> [HeardRepeaterEntity] { + Array(try observations().prefix(5)) + } + + private func observations() throws -> [HeardRepeaterEntity] { + guard let snapshot = try MeshMapperSiriSnapshotStore.shared.read() else { + return [] + } + let repeaterById = Dictionary( + snapshot.repeaters.map { ($0.id, $0) }, + uniquingKeysWith: { existing, _ in existing } + ) + return snapshot.recentHeard.map { + HeardRepeaterEntity(observation: $0, repeaterById: repeaterById) + } + } +} diff --git a/ios/Shared/AppIntents/MeshMapperSiriSnapshot.swift b/ios/Shared/AppIntents/MeshMapperSiriSnapshot.swift new file mode 100644 index 00000000..4fb3ffe4 --- /dev/null +++ b/ios/Shared/AppIntents/MeshMapperSiriSnapshot.swift @@ -0,0 +1,99 @@ +import Foundation + +struct MeshMapperSiriSnapshot: Codable, Sendable { + let version: Int + let updatedAtMs: Int64 + let connection: MeshMapperSiriConnection + let session: MeshMapperSiriSession + let controls: MeshMapperSiriControls + let recentHeard: [MeshMapperSiriObservation] + let repeaters: [MeshMapperSiriRepeater] + + var updatedAt: Date { + Date(timeIntervalSince1970: Double(updatedAtMs) / 1_000) + } +} + +struct MeshMapperSiriConnection: Codable, Sendable { + let isConnected: Bool + let deviceName: String? + let batteryPercent: Int? + let gpsStatus: String +} + +struct MeshMapperSiriSession: Codable, Sendable { + let id: String? + /// Additive and optional, so a snapshot written by an older build still + /// decodes; nil simply means "no session boundary is known". + let startedAtMs: Int64? + let active: Bool + let starting: Bool + let mode: String + let phase: String + let phaseTitle: String + let phaseDetail: String? + let phaseEndsAtMs: Int64? + let zoneCode: String? + let txCount: Int + let rxCount: Int + let discoveryCount: Int + let traceCount: Int + let queueSize: Int + let uniqueRepeatersHeard: Int + + var startedAt: Date? { + startedAtMs.map { Date(timeIntervalSince1970: Double($0) / 1_000) } + } +} + +struct MeshMapperSiriControls: Codable, Sendable { + let availableStartModes: [String] + let canStart: Bool + let startBlockedReason: String? + let canStop: Bool + let canManualPing: Bool + let manualPingBlockedReason: String? + let manualCooldownEndsAtMs: Int64? +} + +struct MeshMapperSiriObservation: Codable, Sendable { + let entityId: String? + let displayHexId: String + let name: String? + let observedAtMs: Int64 + let kind: String + let direct: Bool + let hopCount: Int + let snr: Double? + let rssi: Int? + let distanceM: Double? + let repeaterLat: Double? + let repeaterLon: Double? + let resolved: Bool + + var observedAt: Date { + Date(timeIntervalSince1970: Double(observedAtMs) / 1_000) + } + + var stableEntityIdentifier: String { + [ + entityId ?? displayHexId, + String(observedAtMs), + kind, + direct ? "direct" : "routed", + String(hopCount), + ].joined(separator: "|") + } +} + +struct MeshMapperSiriRepeater: Codable, Sendable { + let id: String + let name: String + let hexId: String + let zoneCode: String? + let isActive: Bool + let isNew: Bool + let serverLastHeardMs: Int64? + let latitude: Double? + let longitude: Double? +} diff --git a/ios/Shared/AppIntents/MeshMapperSiriSnapshotStore.swift b/ios/Shared/AppIntents/MeshMapperSiriSnapshotStore.swift new file mode 100644 index 00000000..cf752afe --- /dev/null +++ b/ios/Shared/AppIntents/MeshMapperSiriSnapshotStore.swift @@ -0,0 +1,89 @@ +import Foundation + +enum MeshMapperSiriSnapshotStoreError: LocalizedError { + case appGroupUnavailable + case invalidPayload + case unsupportedVersion(Int) + + var errorDescription: String? { + switch self { + case .appGroupUnavailable: + return "The MeshMapper App Group is unavailable." + case .invalidPayload: + return "The Siri snapshot payload is invalid." + case .unsupportedVersion(let version): + return "Unsupported Siri snapshot version \(version)." + } + } +} + +final class MeshMapperSiriSnapshotStore { + static let shared = MeshMapperSiriSnapshotStore() + static let appGroupIdentifier = "group.net.meshmapper.app.shared" + static let supportedVersion = 1 + + // The historical Siri name and filename are a v1 wire-compatibility detail, + // not an ownership boundary. This is deliberately a Foundation-only, + // low-frequency projection of phone-owned state so future native surfaces + // such as widgets or a CarPlay scene can share it without importing Flutter + // or routing reads through SiriIntentCoordinator. Additive optional fields + // stay at version 1; bump only for a meaning change or removal. + + private let fileManager: FileManager + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + init(fileManager: FileManager = .default) { + self.fileManager = fileManager + encoder.outputFormatting = [.sortedKeys] + } + + private var snapshotURL: URL? { + fileManager + .containerURL( + forSecurityApplicationGroupIdentifier: Self.appGroupIdentifier + )? + // Keep the installed v1 filename even if the Swift types are generalized + // later; changing it would strand an extension during an app update. + .appendingPathComponent("siri-snapshot.json", isDirectory: false) + } + + func writeFlutterPayload(_ arguments: Any?) throws { + guard let arguments, + JSONSerialization.isValidJSONObject(arguments) + else { + throw MeshMapperSiriSnapshotStoreError.invalidPayload + } + let incoming = try JSONSerialization.data( + withJSONObject: arguments, + options: [.sortedKeys] + ) + let snapshot = try decoder.decode(MeshMapperSiriSnapshot.self, from: incoming) + try write(snapshot) + } + + func write(_ snapshot: MeshMapperSiriSnapshot) throws { + guard snapshot.version == Self.supportedVersion else { + throw MeshMapperSiriSnapshotStoreError.unsupportedVersion(snapshot.version) + } + guard let snapshotURL else { + throw MeshMapperSiriSnapshotStoreError.appGroupUnavailable + } + let data = try encoder.encode(snapshot) + try data.write(to: snapshotURL, options: .atomic) + } + + func read() throws -> MeshMapperSiriSnapshot? { + guard let snapshotURL else { + throw MeshMapperSiriSnapshotStoreError.appGroupUnavailable + } + guard fileManager.fileExists(atPath: snapshotURL.path) else { return nil } + let data = try Data(contentsOf: snapshotURL) + let snapshot = try decoder.decode(MeshMapperSiriSnapshot.self, from: data) + guard snapshot.version == Self.supportedVersion else { + throw MeshMapperSiriSnapshotStoreError.unsupportedVersion(snapshot.version) + } + return snapshot + } + +} diff --git a/ios/Shared/AppIntents/RepeaterEntity.swift b/ios/Shared/AppIntents/RepeaterEntity.swift new file mode 100644 index 00000000..d2a2f6ab --- /dev/null +++ b/ios/Shared/AppIntents/RepeaterEntity.swift @@ -0,0 +1,71 @@ +import AppIntents +import Foundation + +@available(iOS 26.0, *) +struct RepeaterEntity: AppEntity { + static var typeDisplayRepresentation = TypeDisplayRepresentation( + name: "MeshMapper Repeater", + numericFormat: "\(placeholder: .int) repeaters" + ) + static var defaultQuery = RepeaterEntityQuery() + + let id: String + let name: String + let hexId: String + let zoneCode: String? + let isActive: Bool + let isNew: Bool + let serverLastHeardMs: Int64? + let latitude: Double? + let longitude: Double? + + init(snapshot: MeshMapperSiriRepeater) { + id = snapshot.id + name = snapshot.name + hexId = snapshot.hexId + zoneCode = snapshot.zoneCode + isActive = snapshot.isActive + isNew = snapshot.isNew + serverLastHeardMs = snapshot.serverLastHeardMs + latitude = snapshot.latitude + longitude = snapshot.longitude + } + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: "\(name)", + subtitle: zoneCode.map { "\($0) · \(hexId)" } ?? "\(hexId)" + ) + } + +} + +@available(iOS 26.0, *) +struct RepeaterEntityQuery: EntityStringQuery { + func entities(for identifiers: [RepeaterEntity.ID]) async throws -> [RepeaterEntity] { + let wanted = Set(identifiers) + return try catalog().filter { wanted.contains($0.id) } + } + + func entities(matching string: String) async throws -> [RepeaterEntity] { + let needle = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard !needle.isEmpty else { + return Array(try catalog().filter(\.isActive).prefix(20)) + } + return try catalog().filter { + $0.name.localizedCaseInsensitiveContains(needle) + || $0.hexId.localizedCaseInsensitiveContains(needle) + || ($0.zoneCode?.localizedCaseInsensitiveContains(needle) ?? false) + } + } + + func suggestedEntities() async throws -> [RepeaterEntity] { + Array(try catalog().filter(\.isActive).prefix(20)) + } + + private func catalog() throws -> [RepeaterEntity] { + try MeshMapperSiriSnapshotStore.shared.read()? + .repeaters + .map(RepeaterEntity.init(snapshot:)) ?? [] + } +} diff --git a/ios/Shared/MeshMapperActivityAttributes.swift b/ios/Shared/MeshMapperActivityAttributes.swift new file mode 100644 index 00000000..eba75da2 --- /dev/null +++ b/ios/Shared/MeshMapperActivityAttributes.swift @@ -0,0 +1,57 @@ +import ActivityKit +import Foundation + +/// Shared ActivityKit contract used by Runner and the widget extension. +/// Keep the payload compact because ActivityKit limits attributes and state. +@available(iOS 16.2, *) +struct MeshMapperActivityAttributes: ActivityAttributes { + /// Same compact sRGB encoding as the watch payload. A separate Swift name + /// avoids coupling the widget target to the watch-only contract source. + struct ResolvedColor: Codable, Hashable { + let r: Double + let g: Double + let b: Double + } + + struct HeardRepeater: Codable, Hashable, Identifiable { + let id: String + let name: String? + let snr: Double + let typeColor: ResolvedColor? + let snrColor: ResolvedColor? + } + + struct ContentState: Codable, Hashable { + var mode: String + var phase: String + var phaseTitle: String + var phaseDetail: String? + var phaseEndsAt: Date? + var phaseDurationMs: Int? + var pingColor: ResolvedColor? + /// The running mode's identity color, phone-resolved from the active + /// ping palette. Tints the lock screen's phase progress bar. + var modeColor: ResolvedColor? = nil + var isConnected: Bool + var zoneCode: String? + /// Bucketed to 2 dBm steps by the phone so poll jitter never mints an + /// update. Defaulted so payloads from an app without the field still + /// decode. + var noiseFloorDbm: Int? = nil + /// Companion radio battery, bucketed to 5 percent steps by the phone. + var companionBatteryPct: Int? = nil + /// The Settings switch: named rows (true) or the two-column hex grid. + var showRepeaterNames: Bool? = nil + var txCount: Int + var rxCount: Int + var discoveryCount: Int + var traceCount: Int + var queueSize: Int + var repeaters: [HeardRepeater] + var totalHeardCount: Int + var repeatersAreCurrent: Bool + var updatedAt: Date + } + + let sessionID: String +} diff --git a/ios/Shared/MeshMapperWatchPayload.swift b/ios/Shared/MeshMapperWatchPayload.swift new file mode 100644 index 00000000..daff69bf --- /dev/null +++ b/ios/Shared/MeshMapperWatchPayload.swift @@ -0,0 +1,587 @@ +import Foundation + +/// Wire contract between the iPhone app and the watchOS companion. +/// +/// Shared source: this file is compiled into both Runner and MeshMapperWatch, +/// so the two can never drift. Dart builds the equivalent JSON in +/// `lib/services/watch/watch_models.dart`; the golden fixtures in +/// `test/services/watch/` are what keep Dart and Swift honest with each other. +/// +/// Design notes that matter for battery and correctness: +/// +/// - **Countdowns are absolute deadlines** (`phaseEndsAt`), never tick counts. +/// The watch renders them with `Text(timerInterval:)`, so an active session +/// sends roughly one update per phase transition rather than one per second. +/// - **Colours are resolved on the phone.** Dart owns the colour-vision +/// palettes, so the watch receives sRGB components and stays dumb. This is +/// why accessibility palettes work on the wrist for free. +/// - **Everything is capped.** WatchConnectivity payloads should stay small; +/// the wrist is a glance surface and the phone is where the full history is. +enum MeshMapperWatchWire { + /// Bump when a field changes meaning or is removed. The receiver refuses + /// payloads it doesn't understand rather than rendering something wrong. + /// + /// v2: heard nodes mirror the app's "Top Heard" map overlay — hex ID and + /// ping-type colour — instead of richer per-echo data. Hop counts are gone: + /// the overlay is fed direct repeaters only. + /// + /// Additive optional fields stay on v2: this decoder defaults an older + /// phone's missing mode list to Passive and missing Ping applicability to + /// false, while an older phone safely ignores a command's new mode field. + /// Rejecting that pair would add no protection. + static let version = 2 + + /// Caps, mirrored in Dart and enforced by `WatchGeoBuilder` on send. + /// + /// The receiving side does not truncate: these values describe what the phone + /// promises to send, and the views size themselves from what actually + /// arrives. A peer that ignored them would render more rows, not crash. + static let maxPings = 60 + static let maxRepeaters = 20 + + /// Three top-SNR rows plus the RX slot. + static let maxHeard = 4 +} + +// MARK: - Colour + +/// An sRGB colour resolved by Dart from the active colour-vision palette. +struct WatchColor: Codable, Hashable { + let r: Double + let g: Double + let b: Double +} + +// MARK: - Geo + +struct WatchPosition: Codable, Hashable { + let lat: Double + let lon: Double + /// Degrees clockwise from true north; nil when the fix has no course. + let headingDeg: Double? + let accuracyM: Double? + /// Milliseconds since epoch, so the watch can age the fix itself. + let fixedAtMs: Double +} + +/// A ping marker, already coloured by outcome. +struct WatchPing: Codable, Hashable, Identifiable { + let id: String + let lat: Double + let lon: Double + /// "tx" | "rx" | "disc" | "trace" — for glyph choice, not colour. + let kind: String + let color: WatchColor + let atMs: Double +} + +/// A repeater pin. `heardThisCycle` drives the highlight ring. +struct WatchRepeater: Codable, Hashable, Identifiable { + let id: String + /// Full repeater hex. Heard path hashes are prefixes of this value; the API + /// database ID above belongs to a different identity domain. + // Optional only for one-version migration: a new watch can receive the + // phone's previously persisted v2 application context before the matching + // app update replaces it. New Dart payloads always provide this field. + let hexId: String? + let name: String + let lat: Double + let lon: Double + let color: WatchColor + let heardThisCycle: Bool +} + +/// One row of the "Top Heard" overlay. +/// +/// Mirrors `_buildTopRepeatersOverlay` on the phone's map: a dot coloured by +/// the kind of ping answered, the hex path-hash ID, and the SNR. +/// +/// The **hex ID is the identity**. Path hashes are 1–3 bytes, so a short ID +/// often maps to more than one repeater; `name` arrives only when the match is +/// unambiguous and is shown as a secondary hint, never in place of the ID. +/// +/// No hop count by design — the overlay is fed direct repeaters only. +struct WatchHeardNode: Codable, Hashable, Identifiable { + /// Uppercase hex, 2/4/6 characters depending on the zone's hop bytes. + let id: String + let name: String? + let snr: Double? + let atMs: Double + let distanceM: Double? + /// SNR traffic-light colour, resolved by Dart. + let snrColor: WatchColor? + /// Ping type answered: green flood/active, teal discovery, cyan trace, + /// purple most-recent RX. + let typeColor: WatchColor +} + +struct WatchGeo: Codable, Hashable { + let you: WatchPosition? + let pings: [WatchPing] + let repeaters: [WatchRepeater] + let heard: [WatchHeardNode] + /// Repeater IDs the last ping reached, for the optional map lines. + let linkedRepeaterIds: [String] +} + +// MARK: - Controls + +/// What the wrist is allowed to do right now. +/// +/// These drive button enablement only. The phone revalidates every command +/// against the same guards the in-app buttons use, so a stale payload can +/// never talk the phone into an illegal transmit. +struct WatchControls: Codable, Hashable { + let canStartStop: Bool + let canManualPing: Bool + let isSessionActive: Bool + /// Stable slot ownership, separate from transient ping enablement. + let manualPingApplicable: Bool + /// Absolute deadline for the 15 s manual cooldown, if one is running. + let manualCooldownEndsAtMs: Double? + /// Human-readable reason a control is unavailable ("Not connected"). + let blockedReason: String? + + init( + canStartStop: Bool, + canManualPing: Bool, + isSessionActive: Bool, + manualPingApplicable: Bool = false, + manualCooldownEndsAtMs: Double?, + blockedReason: String? + ) { + self.canStartStop = canStartStop + self.canManualPing = canManualPing + self.isSessionActive = isSessionActive + self.manualPingApplicable = manualPingApplicable + self.manualCooldownEndsAtMs = manualCooldownEndsAtMs + self.blockedReason = blockedReason + } + + private enum CodingKeys: String, CodingKey { + case canStartStop, canManualPing, isSessionActive + case manualPingApplicable, manualCooldownEndsAtMs, blockedReason + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + canStartStop = try values.decode(Bool.self, forKey: .canStartStop) + canManualPing = try values.decode(Bool.self, forKey: .canManualPing) + isSessionActive = try values.decode(Bool.self, forKey: .isSessionActive) + // Additive v2 field. An older phone cannot promise stable Ping ownership, + // so preserving the established Stop slot is the conservative default. + manualPingApplicable = try values.decodeIfPresent( + Bool.self, + forKey: .manualPingApplicable + ) ?? false + manualCooldownEndsAtMs = try values.decodeIfPresent( + Double.self, + forKey: .manualCooldownEndsAtMs + ) + blockedReason = try values.decodeIfPresent(String.self, forKey: .blockedReason) + } +} + +// MARK: - Haptics + +/// A one-shot event the watch should feel. +/// +/// Carries an `id` so the watch fires exactly once per event: state diffing +/// would double-fire on redelivery, which WatchConnectivity does routinely. +struct WatchHapticCue: Codable, Hashable { + let id: String + /// "success" | "failure" | "notification" + let kind: String + /// Optional for migration, but new phones always send it. A watch must not + /// replay an undated cue retained by an older application context. + let issuedAtMs: Double? + /// Optional is an additive wire change: v2 payloads without it still decode, + /// and the matched phone and watch targets ship the new field together. + let message: String? + + var issuedAt: Date? { + issuedAtMs.map { Date(timeIntervalSince1970: $0 / 1000) } + } +} + +// MARK: - Snapshot + +/// The complete state the watch renders. +struct WatchSnapshot: Codable, Hashable { + let wireVersion: Int + let sessionId: String + + // Session core — mirrors the Live Activity's fields so both surfaces agree. + let mode: String + let phase: String + let phaseTitle: String + let phaseDetail: String? + let phaseEndsAtMs: Double? + /// Total length of the current phase, so the watch can draw a depleting bar + /// locally. Absent when no countdown owns the deadline. + let phaseDurationMs: Int? + let isConnected: Bool + let zoneCode: String? + let txCount: Int + let rxCount: Int + let discoveryCount: Int + let traceCount: Int + let queueSize: Int + + /// Colour of the most recent completed ping result. + let pingColor: WatchColor? + + /// Lowercase mode names the phone currently permits for a wrist start. + /// This is policy resolved by the phone, not enough raw state for the watch + /// to derive policy independently. + let availableStartModes: [String] + + /// False means the map-only arrays were intentionally cleared. Missing on + /// older additive-v2 payloads means full geography, the fail-safe default. + let mapGeoIncluded: Bool + let geo: WatchGeo + let controls: WatchControls + let cue: WatchHapticCue? + let updatedAtMs: Double + + init( + wireVersion: Int, + sessionId: String, + mode: String, + phase: String, + phaseTitle: String, + phaseDetail: String?, + phaseEndsAtMs: Double?, + phaseDurationMs: Int?, + isConnected: Bool, + zoneCode: String?, + txCount: Int, + rxCount: Int, + discoveryCount: Int, + traceCount: Int, + queueSize: Int, + pingColor: WatchColor?, + availableStartModes: [String] = ["passive"], + mapGeoIncluded: Bool = true, + geo: WatchGeo, + controls: WatchControls, + cue: WatchHapticCue?, + updatedAtMs: Double + ) { + self.wireVersion = wireVersion + self.sessionId = sessionId + self.mode = mode + self.phase = phase + self.phaseTitle = phaseTitle + self.phaseDetail = phaseDetail + self.phaseEndsAtMs = phaseEndsAtMs + self.phaseDurationMs = phaseDurationMs + self.isConnected = isConnected + self.zoneCode = zoneCode + self.txCount = txCount + self.rxCount = rxCount + self.discoveryCount = discoveryCount + self.traceCount = traceCount + self.queueSize = queueSize + self.pingColor = pingColor + self.availableStartModes = availableStartModes + self.mapGeoIncluded = mapGeoIncluded + self.geo = geo + self.controls = controls + self.cue = cue + self.updatedAtMs = updatedAtMs + } + + private enum CodingKeys: String, CodingKey { + case wireVersion, sessionId, mode, phase, phaseTitle, phaseDetail + case phaseEndsAtMs, phaseDurationMs, isConnected, zoneCode + case txCount, rxCount, discoveryCount, traceCount, queueSize, pingColor + case availableStartModes, mapGeoIncluded, geo, controls, cue, updatedAtMs + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + wireVersion = try values.decode(Int.self, forKey: .wireVersion) + sessionId = try values.decode(String.self, forKey: .sessionId) + mode = try values.decode(String.self, forKey: .mode) + phase = try values.decode(String.self, forKey: .phase) + phaseTitle = try values.decode(String.self, forKey: .phaseTitle) + phaseDetail = try values.decodeIfPresent(String.self, forKey: .phaseDetail) + phaseEndsAtMs = try values.decodeIfPresent(Double.self, forKey: .phaseEndsAtMs) + phaseDurationMs = try values.decodeIfPresent(Int.self, forKey: .phaseDurationMs) + isConnected = try values.decode(Bool.self, forKey: .isConnected) + zoneCode = try values.decodeIfPresent(String.self, forKey: .zoneCode) + txCount = try values.decode(Int.self, forKey: .txCount) + rxCount = try values.decode(Int.self, forKey: .rxCount) + discoveryCount = try values.decode(Int.self, forKey: .discoveryCount) + traceCount = try values.decode(Int.self, forKey: .traceCount) + queueSize = try values.decode(Int.self, forKey: .queueSize) + pingColor = try values.decodeIfPresent(WatchColor.self, forKey: .pingColor) + // Additive v2 field: an older phone omits it, and Passive is the only mode + // safe to promise without current phone-resolved zone policy. Keeping v2 + // avoids stranding otherwise compatible phone/watch pairs. + availableStartModes = try values.decodeIfPresent( + [String].self, + forKey: .availableStartModes + ) ?? ["passive"] + // Additive v2 field. An old phone always sends full geography, so absence + // must mean included; treating it as suppressed could blank a real map. + mapGeoIncluded = try values.decodeIfPresent( + Bool.self, + forKey: .mapGeoIncluded + ) ?? true + geo = try values.decode(WatchGeo.self, forKey: .geo) + controls = try values.decode(WatchControls.self, forKey: .controls) + cue = try values.decodeIfPresent(WatchHapticCue.self, forKey: .cue) + updatedAtMs = try values.decode(Double.self, forKey: .updatedAtMs) + } + + /// True when this payload came from a wire version the app understands. + var isSupportedVersion: Bool { wireVersion == MeshMapperWatchWire.version } + + var updatedAt: Date { + Date(timeIntervalSince1970: updatedAtMs / 1000) + } + + var phaseEndsAt: Date? { + phaseEndsAtMs.map { Date(timeIntervalSince1970: $0 / 1000) } + } + + /// Fraction of the current phase still to run, 0...1. + /// + /// Computed from absolute values at render time, so it stays correct between + /// updates and when the app opens midway through a phase. Nil when no + /// countdown owns the deadline, in which case no bar is drawn. + func phaseRemainingFraction(at now: Date = Date()) -> Double? { + guard let phaseEndsAt, let phaseDurationMs, phaseDurationMs > 0 else { return nil } + let remaining = phaseEndsAt.timeIntervalSince(now) + guard remaining > 0 else { return 0 } + return min(1, remaining / (Double(phaseDurationMs) / 1000)) + } +} + +// MARK: - Commands (watch → phone) + +/// An intent from the wrist. Never state — the phone decides what happens. +struct WatchCommand: Codable, Hashable { + enum Kind: String, Codable { + case startSession + case stopSession + case manualPing + /// Watch asking for a fresh snapshot (e.g. app just came to the front). + case requestSnapshot + } + + let kind: Kind + /// Optional additive field. Older phones ignore it and retain their safe + /// mode resolver; new phones revalidate it instead of silently downgrading. + let mode: String? + /// Optional map-demand state carried only by requestSnapshot. An old phone + /// ignores it and keeps sending full geography, so wire v2 remains safe. + let mapGeoNeeded: Bool? + /// Whether this is a genuine plea for current state, rather than a change of + /// map demand that happens to travel as the same command. + /// + /// `requestSnapshot` carries both intents. Only the first should defeat the + /// phone's unchanged-state dedupe: the map-geo lease renews every five + /// minutes for as long as the map stays hidden, and forcing an identical + /// snapshot each time would spend the radio precisely where the lease exists + /// to save it. The distinction is stated rather than inferred from + /// `mapGeoNeeded`, which the phone may legitimately resolve to nil when a + /// suppression claim arrives stale or out of order. + /// + /// Optional for the usual reason: absent means false, so a phone paired with + /// an older watch build behaves exactly as it did before. + let forceRefresh: Bool? + /// Client-generated, so the phone can dedupe redelivered commands. + let id: String + /// Queued delivery can outlive the place where a transmit was requested. + /// The phone uses this to reject stale actions before admission. + /// + /// Stamped in the *watch's* clock, deliberately. It doubles as the ordering + /// key for map-geo suppression claims, and rewriting it into phone time + /// would make that key jump the moment an offset is first learned — turning + /// a monotonic sequence into one where a newer claim can look older. + let issuedAtMs: Double + /// The watch's estimate of phone-clock minus watch-clock, in milliseconds. + /// + /// Optional and additive, so an older phone ignores it and behaves exactly as + /// before. It lets the phone measure this command's *age* in its own clock + /// without the two devices having to agree on what time it is: an unshared + /// clock is a normal condition, and the transmit-age window used to turn any + /// disagreement past five seconds into "every command refused" rather than + /// into a slightly wider margin. + /// + /// Learned only from a live `sendMessage`, whose transit is milliseconds. An + /// application context can sit retained for hours, so its timestamp says + /// nothing about the current offset. + let clockOffsetMs: Double? + /// Which session a `stopSession` means, from the snapshot the wearer was + /// looking at when they tapped. + /// + /// Stopping is exempt from the transmit-age window — a late stop takes the + /// radio off air, so refusing it is the worse failure — but that exemption + /// assumed sessions were interchangeable. They are not: a Stop queued while + /// the phone was out of range, delivered after that session ended and + /// another began, silently stopped the wrong one and the wearer learned + /// about it by noticing that recording had halted. + /// + /// Optional and additive. Absent — an older watch build — the phone admits + /// the stop exactly as it always did. + let sessionId: String? +} + +// There is deliberately no acknowledgement model: queued commands have no +// reply channel; state snapshots and failure cues carry every outcome. + +// MARK: - Coding helpers + +extension MeshMapperWatchWire { + static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + return encoder + }() + + static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + return decoder + }() + + /// Key used for the single `Data` blob inside a WatchConnectivity payload. + static let payloadKey = "snapshot" + static let commandKey = "command" +} + +/// The wire's timing rules, as pure functions. +/// +/// These decide what the wearer sees: whether an arriving payload supersedes +/// the one on screen, how old a snapshot is allowed to look before the surface +/// greys, and whether a failure cue is buzzed, shown silently, or dropped. +/// +/// **They live here to be testable.** `WatchSessionClient` is `@Observable`, +/// `@MainActor`, and reaches `WCSession.default` through a computed property +/// with no injection point, so nothing inside it can be exercised without a +/// paired watch. These rules need none of that — a date, an optional offset, +/// and a decoded payload — and this file is already Foundation-only and +/// already compiled into every target that needs it, so moving them costs no +/// project surgery. `WatchLogicTests` runs them under `swift test` on any +/// toolchain; the client keeps the observable state and the timers. +/// +/// Phone-side mirrors live in `lib/services/watch/watch_models.dart`: +/// `WatchWire.cueReadableFor` tracks `staleAfter`, and the phone stops +/// attaching a cue at the same boundary the watch stops presenting one. +enum WatchWireRules { + /// A snapshot older than this is shown greyed with an age badge. The phone + /// only sends on real change, so silence is normal — this threshold is + /// about "the phone has probably gone away", not "no update recently". + static let staleAfter: TimeInterval = 90 + + /// Recent enough that the wearer can still connect a buzz to what they did. + static let cueFreshFor: TimeInterval = 30 + + /// Phone and watch clocks normally agree to well under a second. This is the + /// slack allowed anywhere a phone-stamped time is compared against watch + /// "now", and mirrors the tolerance the phone applies to watch commands. + static let clockTolerance: TimeInterval = 5 + + /// How much of a cue is still worth presenting, given its age. + enum CuePresentation { + /// Recent enough that the wearer can connect the buzz to what they did. + case feel + /// Too old to buzz for, but still the only account of the failure they + /// will ever get: the phone attaches a cue until it passes `staleAfter`, + /// and most of that window is spent with the wrist down. + case read + case drop + } + + /// A phone-stamped instant expressed in this watch's clock. + static func inLocalTime( + _ phoneInstant: Date, + clockOffset: TimeInterval? + ) -> Date { + guard let clockOffset else { return phoneInstant } + return phoneInstant.addingTimeInterval(-clockOffset) + } + + /// A wrist-down interval longer than [cueFreshFor] used to discard the cue + /// outright — no haptic *and* no message — so a wearer who tapped Start, + /// dropped their wrist and looked back a minute later was never told why the + /// session had not begun. Buzzing for something that old is wrong, but + /// staying silent about it is worse. + /// + /// The upper bound is [staleAfter], the same boundary that greys the rest of + /// the screen: past it the wearer is already being told the whole surface is + /// old, and a banner asserting a stale failure as current would be its own + /// lie. + static func presentation( + for cue: WatchHapticCue, + at arrival: Date, + clockOffset: TimeInterval? + ) -> CuePresentation { + guard let issuedAt = cue.issuedAt else { return .drop } + let age = arrival.timeIntervalSince( + inLocalTime(issuedAt, clockOffset: clockOffset) + ) + // A few seconds of skew must not suppress a real failure that has just + // crossed the radio. + guard age >= -clockTolerance else { return .drop } + if age <= cueFreshFor { return .feel } + return age <= staleAfter ? .read : .drop + } + + /// Whether `incoming` supersedes what is already rendered. + /// + /// **The two delivery paths are not ordered with respect to each other.** An + /// urgent update goes out as `sendMessage` *and* as + /// `updateApplicationContext`, and only the first is fast. `sendMessage` + /// does not populate `receivedApplicationContext`, so the retained context + /// keeps whatever last arrived down the slow path — which can be a payload + /// already superseded live. `resume` ingests that context on every wrist + /// raise, and last-writer-wins then walks the wearer backwards onto state + /// that is under 90 s old, so nothing asks for better. + /// + /// Both stamps come from the one phone clock, so they compare raw — + /// `inLocalTime` is for phone-instant against watch-now, which this is not. + /// Equal stamps are accepted: two payloads built in the same millisecond are + /// interchangeable, and a forced refresh always restamps `updatedAt`, so it + /// can never be mistaken for a replay of itself. + /// + /// **A stale surface accepts anything.** Ordering is worth enforcing only + /// while what is held is still worth protecting. Bounding it this way means + /// the worst case of a phone clock stepping backwards is 90 seconds of + /// refusal, after which the boundary flips and the watch recovers on its + /// own. A bare comparison would refuse for the life of the process. + static func supersedes( + _ incoming: WatchSnapshot, + current: WatchSnapshot?, + isStale: Bool + ) -> Bool { + guard let current, !isStale else { return true } + return incoming.updatedAt >= current.updatedAt + } + + /// When a snapshot should be aged from, and how long it has left. + /// + /// Age from when the phone built the payload rather than when it reached the + /// wrist. Launch ingests whatever application context WatchConnectivity + /// retained, which can be hours old, and stamping arrival there would + /// present long-dead state as fresh for a full 90 seconds. + /// + /// Clamped to `arrival` so a phone clock running fast cannot date a snapshot + /// into the future and extend its life, with [clockTolerance] of slack so + /// ordinary skew does not age a genuinely live payload early. + /// + /// A non-positive `remaining` means the payload is already stale on arrival. + static func reception( + arrival: Date, + producedAt: Date?, + clockOffset: TimeInterval? + ) -> (origin: Date, remaining: TimeInterval) { + let origin = + producedAt + .map { inLocalTime($0, clockOffset: clockOffset) } + .map { min(arrival, $0.addingTimeInterval(clockTolerance)) } ?? arrival + return (origin, staleAfter - arrival.timeIntervalSince(origin)) + } +} diff --git a/ios/WatchLogicTests/Package.swift b/ios/WatchLogicTests/Package.swift new file mode 100644 index 00000000..be2d834f --- /dev/null +++ b/ios/WatchLogicTests/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version:5.9 +import PackageDescription + +// Unit tests for the watch wire's decision logic. +// +// A package rather than an Xcode unit-test bundle, deliberately. A watchOS test +// target needs a host app, a simulator and a signing identity, and adding one +// means hand-editing a project file this branch has already had to correct once +// for build-phase ordering — all to test code that depends on none of it. +// `WatchWireRules` and the payload models are Foundation-only, so `swift test` +// exercises them on any toolchain, with no Xcode project involved at all. +// +// `Sources/WatchWire/MeshMapperWatchPayload.swift` is a symlink to the shipping +// file in `ios/Shared`, which SwiftPM requires to live under the package root. +// A symlink rather than a copy so there is nothing to drift: the tests compile +// the same bytes the app targets do. +let package = Package( + name: "WatchLogicTests", + platforms: [.macOS(.v13)], + targets: [ + .target(name: "WatchWire"), + .testTarget(name: "WatchLogicTests", dependencies: ["WatchWire"]), + ] +) diff --git a/ios/WatchLogicTests/Sources/WatchWire/MeshMapperWatchPayload.swift b/ios/WatchLogicTests/Sources/WatchWire/MeshMapperWatchPayload.swift new file mode 120000 index 00000000..2485d55e --- /dev/null +++ b/ios/WatchLogicTests/Sources/WatchWire/MeshMapperWatchPayload.swift @@ -0,0 +1 @@ +../../../Shared/MeshMapperWatchPayload.swift \ No newline at end of file diff --git a/ios/WatchLogicTests/Tests/WatchLogicTests/WatchWireRulesTests.swift b/ios/WatchLogicTests/Tests/WatchLogicTests/WatchWireRulesTests.swift new file mode 100644 index 00000000..33f636d9 --- /dev/null +++ b/ios/WatchLogicTests/Tests/WatchLogicTests/WatchWireRulesTests.swift @@ -0,0 +1,275 @@ +import Foundation +import XCTest + +@testable import WatchWire + +/// Tests for the wire's timing rules — the decisions that pick what the wearer +/// sees. Everything here was previously reachable only through +/// `WatchSessionClient`, which needs a paired watch to exercise at all. +final class WatchWireRulesTests: XCTestCase { + private let base = Date(timeIntervalSince1970: 1_770_000_000) + + private func snapshot(updatedAt: Date, sessionId: String = "s1") -> WatchSnapshot { + WatchSnapshot( + wireVersion: 2, + sessionId: sessionId, + mode: "Passive", + phase: "active", + phaseTitle: "Passive active", + phaseDetail: nil, + phaseEndsAtMs: nil, + phaseDurationMs: nil, + isConnected: true, + zoneCode: "SEA", + txCount: 0, + rxCount: 0, + discoveryCount: 0, + traceCount: 0, + queueSize: 0, + pingColor: nil, + geo: WatchGeo( + you: nil, + pings: [], + repeaters: [], + heard: [], + linkedRepeaterIds: [] + ), + controls: WatchControls( + canStartStop: true, + canManualPing: false, + isSessionActive: true, + manualPingApplicable: false, + manualCooldownEndsAtMs: nil, + blockedReason: nil + ), + cue: nil, + updatedAtMs: updatedAt.timeIntervalSince1970 * 1000 + ) + } + + private func cue(issuedAt: Date?) -> WatchHapticCue { + WatchHapticCue( + id: "cue-1", + kind: "failure", + issuedAtMs: issuedAt.map { $0.timeIntervalSince1970 * 1000 }, + message: "Could not start" + ) + } + + // MARK: - Ordering + + func testFirstSnapshotIsAlwaysAccepted() { + XCTAssertTrue( + WatchWireRules.supersedes( + snapshot(updatedAt: base), current: nil, isStale: false + ) + ) + } + + func testNewerSnapshotSupersedes() { + XCTAssertTrue( + WatchWireRules.supersedes( + snapshot(updatedAt: base.addingTimeInterval(1)), + current: snapshot(updatedAt: base), + isStale: false + ) + ) + } + + /// The defect this rule exists for. The phone sends P2 down both paths, the + /// watch applies P2 from the live message, the wrist drops and rises before + /// context P2 arrives, and `resume` ingests a retained context still holding + /// P1. Last-writer-wins walked the wearer backwards onto state too young to + /// look stale, so nothing asked for anything better. + func testASlowRetainedContextCannotOverwriteANewerLiveOne() { + let p1 = snapshot(updatedAt: base) + let p2 = snapshot(updatedAt: base.addingTimeInterval(5)) + + XCTAssertFalse( + WatchWireRules.supersedes(p1, current: p2, isStale: false), + "an out-of-order application context must not replace a newer live payload" + ) + } + + /// A forced refresh restamps `updatedAt`, so it is strictly newer and can + /// never be mistaken for a replay of itself. Equal stamps are still accepted: + /// two payloads built in the same millisecond are interchangeable. + func testIdenticalTimestampsAreAccepted() { + XCTAssertTrue( + WatchWireRules.supersedes( + snapshot(updatedAt: base), + current: snapshot(updatedAt: base), + isStale: false + ) + ) + } + + /// The bound that keeps a backwards phone clock from being permanent. Once + /// the held snapshot is stale, anything is better than what is on screen. + func testAStaleSurfaceAcceptsAnythingSoAClockStepCannotStall() { + let older = snapshot(updatedAt: base) + let newer = snapshot(updatedAt: base.addingTimeInterval(60)) + + XCTAssertFalse( + WatchWireRules.supersedes(older, current: newer, isStale: false) + ) + XCTAssertTrue( + WatchWireRules.supersedes(older, current: newer, isStale: true), + "refusal must lift at the stale boundary, or the watch never recovers" + ) + } + + // MARK: - Cue presentation + + func testAFreshCueIsFelt() { + XCTAssertEqual( + WatchWireRules.presentation( + for: cue(issuedAt: base), at: base.addingTimeInterval(5), + clockOffset: nil + ), + .feel + ) + } + + /// The wrist-down case. Too old to buzz for, but still the only account of + /// the failure the wearer will ever get. + func testACueTooOldToFeelIsStillRead() { + XCTAssertEqual( + WatchWireRules.presentation( + for: cue(issuedAt: base), at: base.addingTimeInterval(45), + clockOffset: nil + ), + .read + ) + } + + func testACueIsDroppedAtTheStaleBoundary() { + XCTAssertEqual( + WatchWireRules.presentation( + for: cue(issuedAt: base), + at: base.addingTimeInterval(WatchWireRules.staleAfter + 1), + clockOffset: nil + ), + .drop, + "past the boundary the whole surface reads as old; a cue must not claim to be current" + ) + } + + /// The phone stops attaching a cue at exactly this boundary + /// (`WatchWire.cueReadableFor`). If the two ever drift apart, one side is + /// sending bytes the other discards, or dropping a failure the other expects + /// to show. + func testTheReadWindowEndsWhereTheSurfaceGoesStale() { + XCTAssertEqual( + WatchWireRules.presentation( + for: cue(issuedAt: base), + at: base.addingTimeInterval(WatchWireRules.staleAfter - 1), + clockOffset: nil + ), + .read + ) + } + + func testAnUndatedCueIsDroppedRatherThanReplayed() { + XCTAssertEqual( + WatchWireRules.presentation(for: cue(issuedAt: nil), at: base, clockOffset: nil), + .drop + ) + } + + /// A few seconds of skew must not suppress a real failure that has just + /// crossed the radio, but a wildly future-dated one is not credible. + func testSkewIsToleratedButAFutureCueIsNot() { + XCTAssertEqual( + WatchWireRules.presentation( + for: cue(issuedAt: base.addingTimeInterval(2)), at: base, clockOffset: nil + ), + .feel + ) + XCTAssertEqual( + WatchWireRules.presentation( + for: cue(issuedAt: base.addingTimeInterval(120)), at: base, clockOffset: nil + ), + .drop + ) + } + + /// The offset converts a phone stamp into watch time. Without it applied, a + /// phone running a minute ahead would look like a cue from the future and be + /// dropped — the failure the offset exists to prevent. + func testTheClockOffsetIsAppliedBeforeAging() { + let phoneAheadBy: TimeInterval = 60 + let issued = base.addingTimeInterval(phoneAheadBy) + + XCTAssertEqual( + WatchWireRules.presentation(for: cue(issuedAt: issued), at: base, clockOffset: nil), + .drop + ) + XCTAssertEqual( + WatchWireRules.presentation( + for: cue(issuedAt: issued), at: base, clockOffset: phoneAheadBy + ), + .feel + ) + } + + // MARK: - Reception age + + func testAgeRunsFromWhenThePhoneBuiltThePayload() { + // Launch ingests a retained context that can be hours old. Stamping + // arrival would present long-dead state as fresh for a full 90 seconds. + // + // Note the direction of the tolerance: origin is `producedAt` plus + // `clockTolerance`, not `producedAt`. The slack is spent making a payload + // look *younger*, so ordinary skew cannot age a genuinely live one early — + // it is not a symmetric error bar. A snapshot therefore reads up to five + // seconds fresher than it is, which is the intended trade and the reason + // the clamp to `arrival` below has to exist. + let produced = base + let arrival = base.addingTimeInterval(60) + let result = WatchWireRules.reception( + arrival: arrival, producedAt: produced, clockOffset: nil + ) + + XCTAssertEqual( + result.origin, + produced.addingTimeInterval(WatchWireRules.clockTolerance) + ) + XCTAssertEqual( + result.remaining, + WatchWireRules.staleAfter - 60 + WatchWireRules.clockTolerance, + accuracy: 0.001 + ) + } + + func testARetainedContextArrivesAlreadyStale() { + let result = WatchWireRules.reception( + arrival: base.addingTimeInterval(3600), producedAt: base, clockOffset: nil + ) + + XCTAssertLessThanOrEqual(result.remaining, 0) + } + + /// A phone clock running fast must not be able to date a payload into the + /// future and extend its life beyond the boundary. + func testAFastPhoneClockCannotExtendASnapshotsLife() { + let arrival = base + let result = WatchWireRules.reception( + arrival: arrival, + producedAt: base.addingTimeInterval(600), + clockOffset: nil + ) + + XCTAssertEqual(result.origin, arrival, "origin is clamped to arrival") + XCTAssertEqual(result.remaining, WatchWireRules.staleAfter, accuracy: 0.001) + } + + func testAnUndatedPayloadAgesFromArrival() { + let result = WatchWireRules.reception( + arrival: base, producedAt: nil, clockOffset: nil + ) + + XCTAssertEqual(result.origin, base) + XCTAssertEqual(result.remaining, WatchWireRules.staleAfter, accuracy: 0.001) + } +} diff --git a/lib/main.dart b/lib/main.dart index 49efe51b..badf9739 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; @@ -14,12 +15,21 @@ import 'services/bluetooth/bluetooth_service.dart'; import 'services/bluetooth/mobile_bluetooth.dart'; import 'services/bluetooth/web_bluetooth.dart'; import 'services/background_service.dart'; +import 'services/app_intents/app_intent_bridge_service.dart'; import 'services/debug_file_logger.dart'; +import 'services/offline_map_service.dart'; +import 'services/watch/watch_bridge_service.dart'; import 'utils/debug_logger_io.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + // Before anything slow. A watch command can launch this process, and the + // native relay fires as soon as WatchConnectivity delivers — long before the + // provider finishes initializing and starts listening. + WatchBridgeService.reserveCommandQueue(); + AppIntentBridgeService.reserveCommandQueue(); + // Enable debug file logging FIRST on mobile to capture early logs // This must happen before DebugLogger.initialize() to capture early logs if (!kIsWeb) { @@ -58,9 +68,16 @@ void main() async { } debugLog('[APP] Noise floor session adapters registered'); - // Request permissions on startup for mobile platforms + // Deferred rather than awaited. MeshMapperEngine starts this isolate before + // any Activity attaches — an Android Auto head unit can start the process with + // none at all — and permission_handler needs an Activity to raise its dialog + // from. The first frame is the earliest point one is guaranteed. In a headless + // car start no frame is produced, which is exactly when we should not prompt; + // this callback stays pending and fires if the user later opens the app. if (!kIsWeb) { - await _requestPermissions(); + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_requestPermissions()); + }); } // Clean up any orphaned background service from a previous session @@ -69,6 +86,11 @@ void main() async { await BackgroundServiceManager.cleanupOrphanedService(); } + // Clean up any stale offline map download notification + if (!kIsWeb) { + await OfflineMapService().cleanupOrphanedNotification(); + } + runApp(MeshMapperApp(initialThemeMode: initialThemeMode)); } @@ -85,13 +107,10 @@ Future _loadInitialThemeMode() async { } } } catch (e) { - debugLog('[HIVE] Failed to load initial theme: $e - deleting corrupt box'); - // Delete corrupt box so AppStateProvider gets a clean start - try { - await Hive.deleteBoxFromDisk('user_preferences'); - } catch (e) { - debugLog('[INIT] Failed to delete corrupt preferences box: $e'); - } + debugLog('[HIVE] Initial theme load failed (non-fatal): $e'); + // Do NOT delete the box here. A transient open timeout would wipe every + // saved setting. AppStateProvider's _attemptHiveRecovery handles real + // corruption later with a user-visible logError() notification. } return 'dark'; // Default to dark mode } @@ -100,13 +119,20 @@ Future _loadInitialThemeMode() async { Future _requestPermissions() async { debugLog('[APP] Requesting permissions...'); - if (Platform.isIOS) { - // iOS: Use Geolocator for location (permission_handler is unreliable on iOS) - // and trigger Core Bluetooth to prompt for Bluetooth permission - await _requestiOSPermissions(); - } else { - // Android: Use permission_handler - await _requestAndroidPermissions(); + try { + if (Platform.isIOS) { + // iOS: Use Geolocator for location (permission_handler is unreliable on iOS) + // and trigger Core Bluetooth to prompt for Bluetooth permission + await _requestiOSPermissions(); + } else { + // Android: Use permission_handler + await _requestAndroidPermissions(); + } + } catch (e) { + // Never fatal. A permission we could not ask for surfaces later as a + // capability the user can grant from Settings; taking startup down over it + // would cost far more than the missing prompt. + debugLog('[APP] Permission request failed (non-fatal): $e'); } } @@ -114,7 +140,8 @@ Future _requestPermissions() async { Future _requestiOSPermissions() async { // Note: Location permission is now requested AFTER showing the prominent disclosure // dialog in MainScaffold (required for Google Play compliance) - debugLog('[APP] iOS: Skipping location permission (handled after disclosure)'); + debugLog( + '[APP] iOS: Skipping location permission (handled after disclosure)'); // Trigger Core Bluetooth authorization by checking adapter state // This will cause iOS to show the Bluetooth permission prompt if not already granted @@ -132,7 +159,8 @@ Future _requestiOSPermissions() async { .where((state) => state == fbp.BluetoothAdapterState.on) .first .timeout(const Duration(seconds: 3), onTimeout: () { - debugLog('[APP] iOS: Bluetooth authorization timeout (user may have denied or BT is off)'); + debugLog( + '[APP] iOS: Bluetooth authorization timeout (user may have denied or BT is off)'); return fbp.BluetoothAdapterState.off; }); } @@ -165,36 +193,39 @@ Future _requestAndroidPermissions() async { // Dark theme - Tailwind Slate palette const darkColorScheme = ColorScheme.dark( - primary: Color(0xFF059669), // emerald-600 (main actions) + primary: Color(0xFF059669), // emerald-600 (main actions) onPrimary: Colors.white, - secondary: Color(0xFF0284C7), // sky-600 (TX ping) + secondary: Color(0xFF0284C7), // sky-600 (TX ping) onSecondary: Colors.white, - tertiary: Color(0xFF4F46E5), // indigo-600 (auto modes) + tertiary: Color(0xFF4F46E5), // indigo-600 (auto modes) onTertiary: Colors.white, - surface: Color(0xFF1E293B), // slate-800 (cards/panels) - onSurface: Color(0xFFF1F5F9), // slate-100 (primary text) - onSurfaceVariant: Color(0xFFCBD5E1), // slate-300 (muted text, brighter for contrast) + surface: Color(0xFF1E293B), // slate-800 (cards/panels) + onSurface: Color(0xFFF1F5F9), // slate-100 (primary text) + onSurfaceVariant: + Color(0xFFCBD5E1), // slate-300 (muted text, brighter for contrast) surfaceContainerHighest: Color(0xFF0F172A), // slate-900 (main bg) - outline: Color(0xFF334155), // slate-700 (borders) - error: Color(0xFFF87171), // red-400 + outline: Color(0xFF334155), // slate-700 (borders) + error: Color(0xFFF87171), // red-400 onError: Colors.white, ); // Light theme - Tailwind Slate palette (inverted) // Note: Using darker grays for better text contrast const lightColorScheme = ColorScheme.light( - primary: Color(0xFF059669), // emerald-600 + primary: Color(0xFF059669), // emerald-600 onPrimary: Colors.white, - secondary: Color(0xFF0284C7), // sky-600 + secondary: Color(0xFF0284C7), // sky-600 onSecondary: Colors.white, - tertiary: Color(0xFF4F46E5), // indigo-600 + tertiary: Color(0xFF4F46E5), // indigo-600 onTertiary: Colors.white, - surface: Color(0xFFF8FAFC), // slate-50 (cards/panels) - onSurface: Color(0xFF0F172A), // slate-900 (primary text - darker for contrast) - onSurfaceVariant: Color(0xFF475569), // slate-600 (muted text - darker for readability) + surface: Color(0xFFF8FAFC), // slate-50 (cards/panels) + onSurface: + Color(0xFF0F172A), // slate-900 (primary text - darker for contrast) + onSurfaceVariant: + Color(0xFF475569), // slate-600 (muted text - darker for readability) surfaceContainerHighest: Color(0xFFFFFFFF), // white (main bg) - outline: Color(0xFFCBD5E1), // slate-300 (borders) - error: Color(0xFFDC2626), // red-600 + outline: Color(0xFFCBD5E1), // slate-300 (borders) + error: Color(0xFFDC2626), // red-600 onError: Colors.white, ); @@ -206,15 +237,17 @@ class MeshMapperApp extends StatelessWidget { @override Widget build(BuildContext context) { // Create platform-appropriate Bluetooth service - final BluetoothService bluetoothService = kIsWeb - ? WebBluetoothService() - : MobileBluetoothService(); + final BluetoothService bluetoothService = + kIsWeb ? WebBluetoothService() : MobileBluetoothService(); return MultiProvider( providers: [ ChangeNotifierProvider( create: (_) => AppStateProvider(bluetoothService: bluetoothService), ), + ChangeNotifierProvider( + create: (_) => OfflineMapService()..initialize(), + ), ], child: _ThemedApp(initialThemeMode: initialThemeMode), ); @@ -247,12 +280,21 @@ class _ThemedAppState extends State<_ThemedApp> { return MaterialApp( title: 'MeshMapper', + builder: (context, child) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.noScaling, + ), + child: child!, + ); + }, theme: ThemeData( colorScheme: lightColorScheme, scaffoldBackgroundColor: const Color(0xFFF1F5F9), // slate-100 appBarTheme: const AppBarTheme( backgroundColor: Color(0xFFF8FAFC), // slate-50 - foregroundColor: Color(0xFF0F172A), // slate-900 (darker for contrast) + foregroundColor: + Color(0xFF0F172A), // slate-900 (darker for contrast) ), cardTheme: CardThemeData( color: const Color(0xFFF8FAFC), // slate-50 diff --git a/lib/models/api_queue_item.dart b/lib/models/api_queue_item.dart index 57145e96..faa7fdf5 100644 --- a/lib/models/api_queue_item.dart +++ b/lib/models/api_queue_item.dart @@ -52,6 +52,18 @@ class ApiQueueItem extends HiveObject { @HiveField(14) final bool externalAntenna; + /// Radio power in watts (e.g., 0.3, 1.0, 2.0) — included in every API post + @HiveField(15) + final double? power; + + /// TX wire-tag ping counter (token mode only; null otherwise). + @HiveField(16) + final int? pingCounter; + + /// TX wire-tag body sent on the air, e.g. "MM:FlmLG4I" (token mode only; null otherwise). + @HiveField(17) + final String? wireTag; + ApiQueueItem({ required this.type, required this.latitude, @@ -63,6 +75,9 @@ class ApiQueueItem extends HiveObject { this.retryCount = 0, this.lastRetryAt, this.noiseFloor, + this.power, + this.pingCounter, + this.wireTag, }); /// Create from TX ping @@ -74,6 +89,9 @@ class ApiQueueItem extends HiveObject { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, + int? pingCounter, + String? wireTag, }) { return ApiQueueItem( type: 'TX', @@ -81,9 +99,13 @@ class ApiQueueItem extends HiveObject { longitude: longitude, timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), heardRepeats: heardRepeats, - canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate — flush timer controls upload timing + canUploadAfter: DateTime.now() + .millisecondsSinceEpoch, // Immediate — flush timer controls upload timing externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, + pingCounter: pingCounter, + wireTag: wireTag, ); } @@ -96,6 +118,7 @@ class ApiQueueItem extends HiveObject { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) { return ApiQueueItem( type: 'RX', @@ -106,6 +129,7 @@ class ApiQueueItem extends HiveObject { canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); } @@ -123,9 +147,11 @@ class ApiQueueItem extends HiveObject { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) { // Format: "repeaterId:nodeType:localSnr:localRssi:remoteSnr:pubkeyFull" - final heardRepeats = '$repeaterId:$nodeType:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}:$pubkeyFull'; + final heardRepeats = + '$repeaterId:$nodeType:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}:$pubkeyFull'; return ApiQueueItem( type: 'DISC', latitude: latitude, @@ -135,6 +161,36 @@ class ApiQueueItem extends HiveObject { canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, + ); + } + + /// Create from a successful TRACE ping (targeted zero-hop trace) + /// heardRepeats format: "repeaterId:localSnr:localRssi:remoteSnr" + factory ApiQueueItem.fromTrace({ + required double latitude, + required double longitude, + required String repeaterId, + required double localSnr, + required int localRssi, + required double remoteSnr, + required int timestamp, + required bool externalAntenna, + int? noiseFloor, + double? power, + }) { + final heardRepeats = + '$repeaterId:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}'; + return ApiQueueItem( + type: 'TRACE', + latitude: latitude, + longitude: longitude, + timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), + heardRepeats: heardRepeats, + canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate + externalAntenna: externalAntenna, + noiseFloor: noiseFloor, + power: power, ); } @@ -145,6 +201,7 @@ class ApiQueueItem extends HiveObject { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) { return ApiQueueItem( type: 'DISC', @@ -155,11 +212,31 @@ class ApiQueueItem extends HiveObject { canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); } /// Convert to API JSON format (matches WebClient exactly) Map toApiJson() { + // For TRACE type, parse the heardRepeats field to extract individual values + if (type == 'TRACE') { + // Format: "repeaterId:localSnr:localRssi:remoteSnr" + final parts = heardRepeats.split(':'); + return { + 'type': type, + 'lat': latitude, + 'lon': longitude, + 'noisefloor': noiseFloor, + 'repeater_id': parts.isNotEmpty ? parts[0] : '', + 'local_snr': parts.length > 1 ? double.tryParse(parts[1]) : null, + 'local_rssi': parts.length > 2 ? int.tryParse(parts[2]) : null, + 'remote_snr': parts.length > 3 ? double.tryParse(parts[3]) : null, + 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, + 'external_antenna': externalAntenna, + 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, + }; + } + // For DISC type, parse the heardRepeats field to extract individual values if (type == 'DISC') { // Failed discovery (no nodes responded) @@ -172,6 +249,7 @@ class ApiQueueItem extends HiveObject { 'repeater_id': 'None', 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, 'external_antenna': externalAntenna, + 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, }; } @@ -188,8 +266,10 @@ class ApiQueueItem extends HiveObject { 'local_rssi': parts.length > 3 ? int.tryParse(parts[3]) ?? 0 : 0, 'remote_snr': parts.length > 4 ? double.tryParse(parts[4]) ?? 0.0 : 0.0, 'public_key': parts.length > 5 ? parts[5] : '', - 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, // Unix timestamp in seconds + 'timestamp': timestamp.millisecondsSinceEpoch ~/ + 1000, // Unix timestamp in seconds 'external_antenna': externalAntenna, + 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, }; } @@ -199,11 +279,29 @@ class ApiQueueItem extends HiveObject { 'lon': longitude, 'noisefloor': noiseFloor, 'heard_repeats': heardRepeats, - 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, // Unix timestamp in seconds + 'timestamp': + timestamp.millisecondsSinceEpoch ~/ 1000, // Unix timestamp in seconds 'external_antenna': externalAntenna, + 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, + // Token-mode TX only. Their presence selects the server's validated path; + // absence (coords mode / RX) is the unchanged-from-today coords path. + if (pingCounter != null) 'ping_counter': pingCounter, + if (wireTag != null) 'wire_tag': wireTag, }; } + /// Whether this item carries a TX wire-tag claim. + /// + /// A tag only re-derives under the session that minted it, so a tagged item + /// is uploadable ONLY under that session. Once it outlives it the item is + /// undeliverable and gets dropped rather than uploaded, because every + /// alternative is worse: keeping the tag makes the server skip the row while + /// reporting success (a silent loss plus a wire_tag_mismatch warn), and + /// stripping it sends the ping down the coords path where, with no status-4 + /// WAIT row left to join, it inserts as DEAD(3) and paints a GREY "dead" + /// cell on the map for a ping that actually got heard. + bool get hasWireTag => wireTag != null || pingCounter != null; + /// Calculate next retry delay using exponential backoff Duration get nextRetryDelay { // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, max 60s @@ -218,7 +316,8 @@ class ApiQueueItem extends HiveObject { } /// Check if item is eligible for upload based on canUploadAfter - bool get isUploadEligible => DateTime.now().millisecondsSinceEpoch >= canUploadAfter; + bool get isUploadEligible => + DateTime.now().millisecondsSinceEpoch >= canUploadAfter; /// Mark as retried void markRetried() { @@ -228,5 +327,6 @@ class ApiQueueItem extends HiveObject { } @override - String toString() => 'ApiQueueItem($type, $latitude, $longitude, retries=$retryCount)'; + String toString() => + 'ApiQueueItem($type, $latitude, $longitude, retries=$retryCount)'; } diff --git a/lib/models/connection_state.dart b/lib/models/connection_state.dart index d8045983..4b3a9659 100644 --- a/lib/models/connection_state.dart +++ b/lib/models/connection_state.dart @@ -1,17 +1,20 @@ +/// Transport type for MeshCore companion connections. +enum TransportType { ble, tcp, usbSerial } + /// Connection status for Bluetooth devices enum ConnectionStatus { /// Not connected to any device disconnected, - + /// Currently scanning for devices scanning, - + /// Connecting to device connecting, - + /// Connected and ready connected, - + /// Connection error occurred error, } @@ -25,33 +28,36 @@ enum ConnectionStep { /// Auto-reconnecting after unexpected BLE disconnect reconnecting, - /// Step 1: BLE GATT connect - bleConnecting, - + /// Step 1: Transport-level connect (BLE/TCP/USB) + transportConnecting, + /// Step 2: Protocol handshake protocolHandshake, - + /// Step 3: Device info query deviceQuery, - + /// Step 4: Device identification (match device model for display/reporting) powerConfiguration, - + /// Step 5: Time synchronization timeSync, - + /// Step 6: API slot acquisition slotAcquisition, - + /// Step 7: Channel setup (#wardriving) channelSetup, - + /// Step 8: GPS initialization gpsInit, - + /// Step 9: Fully connected and ready connected, - + + /// Disconnecting (cleanup in progress) + disconnecting, + /// Error state error, } @@ -60,13 +66,13 @@ enum ConnectionStep { enum GpsStatus { /// GPS permissions not granted permissionDenied, - + /// GPS is disabled on device disabled, - + /// Searching for GPS signal searching, - + /// GPS lock acquired locked, @@ -85,7 +91,7 @@ extension ConnectionStepExtension on ConnectionStep { return 'Disconnected'; case ConnectionStep.reconnecting: return 'Reconnecting...'; - case ConnectionStep.bleConnecting: + case ConnectionStep.transportConnecting: return 'Connecting to device...'; case ConnectionStep.protocolHandshake: return 'Protocol handshake...'; @@ -103,6 +109,8 @@ extension ConnectionStepExtension on ConnectionStep { return 'Initializing GPS...'; case ConnectionStep.connected: return 'Connected'; + case ConnectionStep.disconnecting: + return 'Disconnecting...'; case ConnectionStep.error: return 'Connection error'; } @@ -115,7 +123,7 @@ extension ConnectionStepExtension on ConnectionStep { return 0; case ConnectionStep.reconnecting: return 0; - case ConnectionStep.bleConnecting: + case ConnectionStep.transportConnecting: return 1; case ConnectionStep.protocolHandshake: return 2; @@ -133,6 +141,8 @@ extension ConnectionStepExtension on ConnectionStep { return 8; case ConnectionStep.connected: return 9; + case ConnectionStep.disconnecting: + return 0; case ConnectionStep.error: return -1; } diff --git a/lib/models/device_model.dart b/lib/models/device_model.dart index 41ff907e..61505f98 100644 --- a/lib/models/device_model.dart +++ b/lib/models/device_model.dart @@ -1,24 +1,24 @@ /// Represents a MeshCore device model with its power configuration. -/// +/// /// This maps to the device-models.json database from the WebClient repo. /// Power configuration is critical for PA amplifier models to prevent hardware damage. class DeviceModel { /// Full manufacturer string reported by device (e.g., "Ikoka Stick-E22-30dBm (Xiao_nrf52)") final String manufacturer; - + /// Short display name (e.g., "Ikoka Stick") final String shortName; - + /// Power setting for wardrive.js (0.3, 0.6, 1.0, 2.0) /// CRITICAL: PA amplifier models require exact values final double power; - + /// Hardware platform (nrf52, esp32, esp32-s3, etc.) final String platform; - + /// Firmware TX power setting in dBm final int txPower; - + /// Additional notes about the device final String notes; @@ -55,7 +55,8 @@ class DeviceModel { } @override - String toString() => 'DeviceModel($shortName, power=$power, txPower=$txPower)'; + String toString() => + 'DeviceModel($shortName, power=$power, txPower=$txPower)'; } /// Container for the full device models database diff --git a/lib/models/log_entry.dart b/lib/models/log_entry.dart index 399da9b2..d8022b16 100644 --- a/lib/models/log_entry.dart +++ b/lib/models/log_entry.dart @@ -5,7 +5,8 @@ class TxLogEntry { final double latitude; final double longitude; final double power; // Power in watts (0.3, 0.6, 1.0, 2.0) - final List events; // Repeaters that heard this ping + final List events; // Direct 1-hop repeaters that heard this ping + final List multiHopEvents; // Multi-hop echoes (2+ hops) TxLogEntry({ required this.timestamp, @@ -13,6 +14,7 @@ class TxLogEntry { required this.longitude, required this.power, required this.events, + this.multiHopEvents = const [], }); /// Get formatted timestamp (HH:MM:SS) @@ -31,15 +33,45 @@ class TxLogEntry { String toCsv() { final eventsStr = events.isEmpty ? 'None' - : events.map((e) => e.snr != null ? '${e.repeaterId}(${e.snr!.toStringAsFixed(2)})' : '${e.repeaterId}(null)').join(','); - return '${timestamp.toIso8601String()},$latitude,$longitude,$power,$eventsStr'; + : events + .map((e) => e.snr != null + ? '${e.repeaterId}(${e.snr!.toStringAsFixed(2)})' + : '${e.repeaterId}(null)') + .join(','); + final multiHopStr = multiHopEvents.isEmpty + ? '' + : ',MH:${multiHopEvents.map((e) => e.snr != null ? '${e.repeaterId}(${e.snr!.toStringAsFixed(2)})' : '${e.repeaterId}(null)').join(',')}'; + return '${timestamp.toIso8601String()},$latitude,$longitude,$power,$eventsStr$multiHopStr'; + } +} + +/// Multi-hop echo event (repeater that relayed a TX ping via 2+ hops) +class MultiHopEchoEvent { + final String repeaterId; + final double? snr; + final int? rssi; + final List pathHops; + + MultiHopEchoEvent({ + required this.repeaterId, + this.snr, + this.rssi, + this.pathHops = const [], + }); + + SnrSeverity? get severity { + if (snr == null) return null; + if (snr! <= -1) return SnrSeverity.poor; + if (snr! <= 5) return SnrSeverity.fair; + return SnrSeverity.good; } } /// RX Event (repeater that heard a TX ping) class RxEvent { final String repeaterId; // Hex ID (e.g., "4e", "b7") - final double? snr; // Signal-to-noise ratio in dB (null for CARpeater pass-through) + final double? + snr; // Signal-to-noise ratio in dB (null for CARpeater pass-through) final int? rssi; // RSSI in dBm (null for CARpeater pass-through) RxEvent({ @@ -68,13 +100,18 @@ class RxEvent { class RxLogEntry { final DateTime timestamp; final String repeaterId; // Hex ID (e.g., "4e", "b7") - final double? snr; // Signal-to-noise ratio in dB (null for CARpeater pass-through) - final int? rssi; // Received signal strength indicator in dBm (null for CARpeater pass-through) + final double? + snr; // Signal-to-noise ratio in dB (null for CARpeater pass-through) + final int? + rssi; // Received signal strength indicator in dBm (null for CARpeater pass-through) final int pathLength; // Number of hops final int header; // Packet header byte final double latitude; final double longitude; + /// Display path hops, origin → ... → us. Already CARpeater-stripped. + final List pathHops; + RxLogEntry({ required this.timestamp, required this.repeaterId, @@ -84,6 +121,7 @@ class RxLogEntry { required this.header, required this.latitude, required this.longitude, + this.pathHops = const [], }); /// Get formatted timestamp (HH:MM:SS) @@ -113,9 +151,65 @@ class RxLogEntry { /// Get CSV row String toCsv() { + final pathStr = pathHops.isEmpty ? '' : pathHops.join('|'); return '${timestamp.toIso8601String()},$repeaterId,${snr ?? 'null'},${rssi ?? 'null'},' '$pathLength,0x${header.toRadixString(16).padLeft(2, '0')},' - '$latitude,$longitude'; + '$latitude,$longitude,$pathStr'; + } +} + +/// Trace Log Entry (targeted zero-hop trace result) +class TraceLogEntry { + final DateTime timestamp; + final double latitude; + final double longitude; + final String targetRepeaterId; + final int? noiseFloor; + final double? localSnr; + final double? remoteSnr; + final int? localRssi; + final bool success; + + TraceLogEntry({ + required this.timestamp, + required this.latitude, + required this.longitude, + required this.targetRepeaterId, + this.noiseFloor, + this.localSnr, + this.remoteSnr, + this.localRssi, + required this.success, + }); + + /// Get formatted timestamp (HH:MM:SS) + String get timeString { + return '${timestamp.hour.toString().padLeft(2, '0')}:' + '${timestamp.minute.toString().padLeft(2, '0')}:' + '${timestamp.second.toString().padLeft(2, '0')}'; + } + + /// Get formatted location (5 decimal places) + String get locationString { + return '${latitude.toStringAsFixed(5)},${longitude.toStringAsFixed(5)}'; + } + + /// Get SNR color severity based on local SNR + SnrSeverity? get severity { + if (localSnr == null) return null; + if (localSnr! <= -1) { + return SnrSeverity.poor; + } else if (localSnr! <= 5) { + return SnrSeverity.fair; + } else { + return SnrSeverity.good; + } + } + + /// Get CSV row + String toCsv() { + return '${timestamp.toIso8601String()},$targetRepeaterId,${localSnr ?? 'null'},${localRssi ?? 'null'},' + '${remoteSnr ?? 'null'},$latitude,$longitude,${noiseFloor ?? ''},$success'; } } @@ -126,6 +220,49 @@ enum SnrSeverity { good, // Green: SNR > 5 dB } +/// Ping type for unified log view +enum PingLogType { tx, rx, disc, trace } + +/// Wrapper for unified chronological ping log view +class UnifiedPingLogEntry implements Comparable { + final PingLogType type; + final DateTime timestamp; + final dynamic entry; + + UnifiedPingLogEntry( + {required this.type, required this.timestamp, required this.entry}); + + TxLogEntry get asTx => entry as TxLogEntry; + RxLogEntry get asRx => entry as RxLogEntry; + DiscLogEntry get asDisc => entry as DiscLogEntry; + TraceLogEntry get asTrace => entry as TraceLogEntry; + + @override + int compareTo(UnifiedPingLogEntry other) => + other.timestamp.compareTo(timestamp); + + String get timeString => switch (type) { + PingLogType.tx => asTx.timeString, + PingLogType.rx => asRx.timeString, + PingLogType.disc => asDisc.timeString, + PingLogType.trace => asTrace.timeString, + }; + + String get locationString => switch (type) { + PingLogType.tx => asTx.locationString, + PingLogType.rx => asRx.locationString, + PingLogType.disc => asDisc.locationString, + PingLogType.trace => asTrace.locationString, + }; + + String toCsv() => switch (type) { + PingLogType.tx => 'TX,${asTx.toCsv()}', + PingLogType.rx => 'RX,${asRx.toCsv()}', + PingLogType.disc => 'DISC,${asDisc.toCsv()}', + PingLogType.trace => 'TRC,${asTrace.toCsv()}', + }; +} + /// User Error Entry for error log class UserErrorEntry { final DateTime timestamp; @@ -153,9 +290,9 @@ class UserErrorEntry { /// Error severity levels enum ErrorSeverity { - info, // Blue: informational messages + info, // Blue: informational messages warning, // Orange: warnings - error, // Red: errors + error, // Red: errors } /// Discovery Log Entry (discovery protocol observation) @@ -194,19 +331,24 @@ class DiscLogEntry { String toCsv() { final nodesStr = discoveredNodes.isEmpty ? 'None' - : discoveredNodes.map((n) => '${n.repeaterId}${n.nodeTypeLabel}(${n.localSnr.toStringAsFixed(2)})').join(','); + : discoveredNodes + .map((n) => + '${n.repeaterId}${n.nodeTypeLabel}(${n.localSnr.toStringAsFixed(2)})') + .join(','); return '${timestamp.toIso8601String()},$latitude,$longitude,${noiseFloor ?? ''},${discoveredNodes.length},$nodesStr'; } } /// Discovered node entry for log display class DiscoveredNodeEntry { - final String repeaterId; // First 2 hex chars of pubkey (e.g., "77", "4E") - final String nodeType; // "REPEATER" or "ROOM" - final double localSnr; // SNR as seen by local device (dB) - final int localRssi; // RSSI as seen by local device (dBm) - final double remoteSnr; // SNR as seen by remote node (dB) - final String? pubkeyHex; // Full public key hex (64 chars) for exact repeater matching + final String + repeaterId; // First N hex chars of pubkey based on hopBytes (e.g., "4E", "4E7A", "4E7A3B") + final String nodeType; // "REPEATER" or "ROOM" + final double localSnr; // SNR as seen by local device (dB) + final int localRssi; // RSSI as seen by local device (dBm) + final double remoteSnr; // SNR as seen by remote node (dB) + final String? + pubkeyHex; // Full public key hex (64 chars) for exact repeater matching DiscoveredNodeEntry({ required this.repeaterId, diff --git a/lib/models/noise_floor_session.dart b/lib/models/noise_floor_session.dart index a9212162..8bdeb735 100644 --- a/lib/models/noise_floor_session.dart +++ b/lib/models/noise_floor_session.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; +import '../utils/ping_colors.dart'; part 'noise_floor_session.g.dart'; @@ -35,6 +36,15 @@ enum PingEventType { @HiveField(4) discFail, // Grey: Discovery no response + + @HiveField(5) + traceSuccess, // Cyan: Trace got response + + @HiveField(6) + traceFail, // Grey: Trace no response + + @HiveField(7) + txMultiHopOnly, // RX color: TX got multi-hop echoes but no direct } /// Repeater info for graph markers @@ -54,11 +64,16 @@ class MarkerRepeaterInfo extends HiveObject { @HiveField(3) final String? pubkeyHex; + /// Hop path for multi-hop echoes. Null = direct 1-hop echo, non-null = multi-hop. + @HiveField(4) + final List? pathHops; + MarkerRepeaterInfo({ required this.repeaterId, required this.snr, required this.rssi, this.pubkeyHex, + this.pathHops, }); } @@ -94,11 +109,14 @@ class PingEventMarker extends HiveObject { /// Get the color for this event type Color get color => switch (type) { - PingEventType.txSuccess => Colors.green, - PingEventType.txFail => Colors.red, - PingEventType.rx => Colors.blue, - PingEventType.discSuccess => Colors.purple, - PingEventType.discFail => Colors.grey, + PingEventType.txSuccess => PingColors.txSuccess, + PingEventType.txFail => PingColors.txFail, + PingEventType.rx => PingColors.rx, + PingEventType.discSuccess => PingColors.discSuccess, + PingEventType.discFail => PingColors.discFail, + PingEventType.traceSuccess => PingColors.traceSuccess, + PingEventType.traceFail => PingColors.noResponse, + PingEventType.txMultiHopOnly => PingColors.rx, }; /// Get a display label for this event type @@ -108,6 +126,9 @@ class PingEventMarker extends HiveObject { PingEventType.rx => 'RX', PingEventType.discSuccess => 'DISC Success', PingEventType.discFail => 'DISC Fail', + PingEventType.traceSuccess => 'Trace Success', + PingEventType.traceFail => 'Trace Fail', + PingEventType.txMultiHopOnly => 'TX Multi-hop', }; } @@ -152,10 +173,11 @@ class NoiseFloorSession extends HiveObject { /// Display name for the mode String get modeDisplay => switch (mode) { - 'active' => 'Active Mode', - 'hybrid' => 'Hybrid Mode', - _ => 'Passive Mode', - }; + 'active' => 'Active Mode', + 'hybrid' => 'Hybrid Mode', + 'targeted' => 'Trace Mode', + _ => 'Passive Mode', + }; /// Formatted duration string (M:SS or H:MM:SS for long sessions) String get durationDisplay { diff --git a/lib/models/ping_data.dart b/lib/models/ping_data.dart index d9d52c8a..09defe62 100644 --- a/lib/models/ping_data.dart +++ b/lib/models/ping_data.dart @@ -7,7 +7,7 @@ part 'ping_data.g.dart'; enum PingType { @HiveField(0) tx, - + @HiveField(1) rx, } @@ -48,7 +48,8 @@ class TxPing { /// Note: power is stored in dBm but the message format uses watts /// The actual message is built in PingService with the correct watts value String toMessageFormat({double? powerWatts}) { - final coordsStr = '${latitude.toStringAsFixed(5)}, ${longitude.toStringAsFixed(5)}'; + final coordsStr = + '${latitude.toStringAsFixed(5)}, ${longitude.toStringAsFixed(5)}'; final pw = powerWatts ?? 0.3; // Default to 0.3w if not provided return '@[MapperBot] $coordsStr [${pw.toStringAsFixed(1)}w]'; } @@ -70,22 +71,26 @@ class TxPing { class RxPing { @HiveField(0) final double latitude; - + @HiveField(1) final double longitude; - + @HiveField(2) final String repeaterId; - + @HiveField(3) final DateTime timestamp; - + @HiveField(4) final double snr; - + @HiveField(5) final int rssi; + /// Display path hops, origin → ... → us. Already CARpeater-stripped. + /// Transient (not persisted to Hive); empty when reloaded from disk. + final List pathHops; + const RxPing({ required this.latitude, required this.longitude, @@ -93,6 +98,7 @@ class RxPing { required this.timestamp, required this.snr, required this.rssi, + this.pathHops = const [], }); Map toApiJson() { @@ -114,12 +120,14 @@ class HeardRepeater { final double? snr; // Best SNR observed (null for CARpeater pass-through) final int? rssi; // RSSI in dBm (null for CARpeater pass-through) final int seenCount; // How many times this repeater was heard + final List? pathHops; // null = direct echo, non-null = multi-hop const HeardRepeater({ required this.repeaterId, this.snr, this.rssi, this.seenCount = 1, + this.pathHops, }); } @@ -139,6 +147,7 @@ class PingStats { final int txCount; final int rxCount; final int discCount; // Discovery count (Passive Mode) + final int traceCount; // Trace count (Targeted Mode) final int successfulUploads; final int failedUploads; final int queuedCount; @@ -147,6 +156,7 @@ class PingStats { this.txCount = 0, this.rxCount = 0, this.discCount = 0, + this.traceCount = 0, this.successfulUploads = 0, this.failedUploads = 0, this.queuedCount = 0, @@ -156,6 +166,7 @@ class PingStats { int? txCount, int? rxCount, int? discCount, + int? traceCount, int? successfulUploads, int? failedUploads, int? queuedCount, @@ -164,6 +175,7 @@ class PingStats { txCount: txCount ?? this.txCount, rxCount: rxCount ?? this.rxCount, discCount: discCount ?? this.discCount, + traceCount: traceCount ?? this.traceCount, successfulUploads: successfulUploads ?? this.successfulUploads, failedUploads: failedUploads ?? this.failedUploads, queuedCount: queuedCount ?? this.queuedCount, diff --git a/lib/models/remembered_device.dart b/lib/models/remembered_device.dart index 2bf9236a..426502c2 100644 --- a/lib/models/remembered_device.dart +++ b/lib/models/remembered_device.dart @@ -1,33 +1,57 @@ -/// Remembered BLE device for quick reconnection +import 'connection_state.dart'; + +/// Remembered device for quick reconnection. +/// Supports BLE, TCP, and USB Serial transports. class RememberedDevice { final String id; final String name; final DateTime lastConnected; + final TransportType transportType; + final String? tcpHost; + final int? tcpPort; + final String? serialPortPath; const RememberedDevice({ required this.id, required this.name, required this.lastConnected, + this.transportType = TransportType.ble, + this.tcpHost, + this.tcpPort, + this.serialPortPath, }); - /// Create from JSON (for persistence) factory RememberedDevice.fromJson(Map json) { return RememberedDevice( id: json['id'] as String, name: json['name'] as String, lastConnected: DateTime.parse(json['lastConnected'] as String), + transportType: _parseTransportType(json['transportType'] as String?), + tcpHost: json['tcpHost'] as String?, + tcpPort: json['tcpPort'] as int?, + serialPortPath: json['serialPortPath'] as String?, ); } - /// Convert to JSON (for persistence) Map toJson() { return { 'id': id, 'name': name, 'lastConnected': lastConnected.toIso8601String(), + 'transportType': transportType.name, + if (tcpHost != null) 'tcpHost': tcpHost, + if (tcpPort != null) 'tcpPort': tcpPort, + if (serialPortPath != null) 'serialPortPath': serialPortPath, }; } - /// Get display name (stripped of MeshCore- prefix) String get displayName => name.replaceFirst('MeshCore-', ''); + + static TransportType _parseTransportType(String? value) { + if (value == null) return TransportType.ble; + return TransportType.values.firstWhere( + (e) => e.name == value, + orElse: () => TransportType.ble, + ); + } } diff --git a/lib/models/repeater.dart b/lib/models/repeater.dart index bbf76bd1..8d5a6207 100644 --- a/lib/models/repeater.dart +++ b/lib/models/repeater.dart @@ -3,6 +3,11 @@ import 'package:intl/intl.dart'; /// Represents a repeater from the MeshMapper API. /// Used to display repeater markers on the map. class Repeater { + /// Zone-level fallback for stale threshold, updated from the status API's + /// `stale_repeater_hours` field. Used only when a repeater lacks a + /// per-repeater [staleTime]. + static int staleHoursFallback = 24; + /// Unique ID (e.g., "01", "92") final String id; @@ -37,6 +42,11 @@ class Repeater { /// Number of bytes per hop hash for this repeater's path (1, 2, or 3) final int hopBytes; + /// Repeater clock skew in seconds reported by the server (+ve = repeater + /// clock behind real time, -ve = ahead), or null when unknown. Drives the + /// "time is not set correctly" warning in the detail sheet. + final int? timeOffset; + const Repeater({ required this.id, required this.hexId, @@ -49,6 +59,7 @@ class Repeater { this.createdAt, this.staleTime, this.hopBytes = 1, + this.timeOffset, }); /// Parse from JSON object in repeaters.json @@ -71,6 +82,17 @@ class Repeater { staleTime = int.tryParse(rawStaleTime); } + // Parse time_offset (repeater clock skew, seconds) which may be int or String + int? timeOffset; + final rawTimeOffset = json['time_offset']; + if (rawTimeOffset is int) { + timeOffset = rawTimeOffset; + } else if (rawTimeOffset is num) { + timeOffset = rawTimeOffset.toInt(); + } else if (rawTimeOffset is String) { + timeOffset = int.tryParse(rawTimeOffset); + } + return Repeater( id: json['id'] as String, hexId: json['hex_id'] as String? ?? '', @@ -83,6 +105,7 @@ class Repeater { createdAt: createdAt, staleTime: staleTime, hopBytes: (json['hop_bytes'] as int?) ?? 1, + timeOffset: timeOffset, ); } @@ -99,6 +122,7 @@ class Repeater { 'created_at': createdAt, 'stale_time': staleTime, 'hop_bytes': hopBytes, + 'time_offset': timeOffset, }; } @@ -112,6 +136,13 @@ class Repeater { /// Check if the repeater is enabled (any non-zero value) bool get isEnabled => enabled != 0; + /// True when the repeater has known GPS coordinates. The API uses + /// `(0, 0)` as a sentinel for "location not yet published" — those + /// repeaters are excluded from map focus geometry (no line, no + /// distance label, not part of the bounds-fit) but still appear in + /// heard-repeater listings with a `location_off` indicator. + bool get hasLocation => lat != 0.0 || lon != 0.0; + /// Check if the repeater was created within the past 7 days bool get isNew { if (createdAt == null) return false; @@ -121,21 +152,29 @@ class Repeater { /// Check if the repeater is active. /// Uses server-provided [staleTime] when available, otherwise falls back - /// to a 24-hour threshold from [lastHeard]. + /// to [staleHoursFallback] (set from the zone's `stale_repeater_hours`). bool get isActive { if (staleTime != null) { final nowSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000; return nowSeconds < staleTime!; } - // Fallback: 24-hour threshold from lastHeard if (lastHeard == 0) return false; final heard = DateTime.fromMillisecondsSinceEpoch(lastHeard * 1000); - return DateTime.now().difference(heard).inHours < 24; + return DateTime.now().difference(heard).inHours < staleHoursFallback; } /// Check if the repeater has not been heard in the past 24 hours bool get isDead => !isActive; + /// True if the repeater has been heard within the past 30 days. Used by + /// the map to hide long-stale repeaters. Returns false when [lastHeard] + /// is 0 (never heard). + bool get isHeardRecently { + if (lastHeard == 0) return false; + final heard = DateTime.fromMillisecondsSinceEpoch(lastHeard * 1000); + return DateTime.now().difference(heard).inDays < 30; + } + /// Get display hex ID based on hop bytes (or override). /// [overrideHopBytes] is used when regional admin enforces a byte size. String displayHexId({int? overrideHopBytes}) { diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index a6537994..f20fa4a9 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -70,9 +70,74 @@ class UserPreferences { /// Anonymous mode: rename companion to "Anonymous" during wardriving final bool anonymousMode; + /// Broadcast coordinates: when true, TX pings put real GPS on the air (legacy + /// behavior). Default false = privacy-preserving wire tag; coords go via API only. + final bool broadcastCoords; + /// Discovery drop: count failed discoveries as failed pings and report to API final bool discDropEnabled; + /// Flood traffic enabled — gates display of Send Ping + Active + Hybrid + /// controls. Default off; auto-flipped by the auth response's flood_disabled + /// flag when the region allows flooding. + final bool floodTrafficEnabled; + + /// Delete wardriving channel from radio on disconnect + final bool deleteChannelOnDisconnect; + + /// Minimum ping distance in meters (25m floor, user can increase) + final int minPingDistanceMeters; + + /// Auto-stop auto-ping after 30 minutes of idle (no movement) + final bool autoStopAfterIdle; + + /// Show top 3 repeaters by SNR on the map during wardriving + final bool showTopRepeaters; + + /// Live Activity heard rows show resolved repeater names (fewer, named + /// rows). When false the card packs a two-column hex grid instead. + final bool liveActivityShowNames; + + /// Coverage marker style on the map (dot, pin, diamond) + final String markerStyle; + + /// GPS position marker style (arrow, car, bike, boat, walk) + final String gpsMarkerStyle; + + /// Color vision type for accessibility (none, protanopia, deuteranopia, tritanopia, achromatopsia) + final String colorVisionType; + + /// Download map tiles (base map + coverage overlay). When false, no tile network requests are made to save mobile data. + final bool mapTilesEnabled; + + /// MeshMapper coverage raster overlay opacity (0.0 = fully transparent, + /// 1.0 = fully opaque). Applied to the `meshmapper-overlay-layer` raster + /// layer so users can see the base map underneath the coverage squares. + final double coverageOverlayOpacity; + + /// Coverage grid preset, matching the web UI's Grid Mode: 300 = Simplified + /// (300 m cells, the default), 100 = Detailed (100 m cells + 3×3 blob, + /// applied server-side). Only these two values are valid. + final int coverageGridSize; + + /// Disconnect alert: play audible alert when pinging stops unexpectedly (BLE disconnect, idle timeout, maintenance) + final bool disconnectAlertEnabled; + + /// Custom API endpoint enabled (forwards wardrive payload to third-party URL) + final bool customApiEnabled; + + /// Custom API endpoint URL (must be HTTPS) + final String? customApiUrl; + + /// Custom API endpoint key (sent as X-API-Key header) + final String? customApiKey; + + /// Whether the user has accepted the third-party data sharing disclaimer + final bool customApiDisclaimerAccepted; + + /// Include device public key prefix in custom API payload (contact field) + final bool customApiIncludeContact; + const UserPreferences({ this.powerLevel = 0.3, this.txPower = 22, @@ -87,17 +152,36 @@ class UserPreferences { this.iataCode, this.backgroundModeEnabled = false, this.developerModeEnabled = false, - this.mapStyle = 'dark', + this.mapStyle = 'liberty', this.closeAppAfterDisconnect = false, this.themeMode = 'dark', this.unitSystem = 'metric', - this.hybridModeEnabled = false, + this.hybridModeEnabled = true, this.mapAutoFollow = false, this.mapAlwaysNorth = true, this.mapRotationLocked = false, this.disableRssiFilter = false, this.anonymousMode = false, + this.broadcastCoords = false, this.discDropEnabled = false, + this.floodTrafficEnabled = false, + this.deleteChannelOnDisconnect = true, + this.minPingDistanceMeters = 25, + this.autoStopAfterIdle = true, + this.showTopRepeaters = false, + this.liveActivityShowNames = true, + this.markerStyle = 'dot', + this.gpsMarkerStyle = 'arrow', + this.colorVisionType = 'none', + this.mapTilesEnabled = true, + this.coverageOverlayOpacity = 0.7, + this.coverageGridSize = 300, + this.disconnectAlertEnabled = false, + this.customApiEnabled = false, + this.customApiUrl, + this.customApiKey, + this.customApiDisclaimerAccepted = false, + this.customApiIncludeContact = true, }); /// Create from JSON (for persistence) @@ -116,20 +200,55 @@ class UserPreferences { iataCode: json['iataCode'] as String?, backgroundModeEnabled: (json['backgroundModeEnabled'] as bool?) ?? false, developerModeEnabled: (json['developerModeEnabled'] as bool?) ?? false, - mapStyle: (json['mapStyle'] as String?) ?? 'dark', - closeAppAfterDisconnect: (json['closeAppAfterDisconnect'] as bool?) ?? false, + mapStyle: (json['mapStyle'] as String?) ?? 'liberty', + closeAppAfterDisconnect: + (json['closeAppAfterDisconnect'] as bool?) ?? false, themeMode: (json['themeMode'] as String?) ?? 'dark', unitSystem: (json['unitSystem'] as String?) ?? 'metric', - hybridModeEnabled: (json['hybridModeEnabled'] as bool?) ?? false, + hybridModeEnabled: (json['hybridModeEnabled'] as bool?) ?? true, mapAutoFollow: (json['mapAutoFollow'] as bool?) ?? false, mapAlwaysNorth: (json['mapAlwaysNorth'] as bool?) ?? true, mapRotationLocked: (json['mapRotationLocked'] as bool?) ?? false, disableRssiFilter: (json['disableRssiFilter'] as bool?) ?? false, anonymousMode: (json['anonymousMode'] as bool?) ?? false, + broadcastCoords: (json['broadcastCoords'] as bool?) ?? false, discDropEnabled: (json['discDropEnabled'] as bool?) ?? false, + floodTrafficEnabled: (json['floodTrafficEnabled'] as bool?) ?? false, + deleteChannelOnDisconnect: + (json['deleteChannelOnDisconnect'] as bool?) ?? true, + minPingDistanceMeters: (json['minPingDistanceMeters'] as int?) ?? 25, + autoStopAfterIdle: (json['autoStopAfterIdle'] as bool?) ?? true, + showTopRepeaters: (json['showTopRepeaters'] as bool?) ?? false, + liveActivityShowNames: (json['liveActivityShowNames'] as bool?) ?? true, + markerStyle: (json['markerStyle'] as String?) ?? 'dot', + gpsMarkerStyle: _migrateGpsMarkerStyle(json['gpsMarkerStyle'] as String?), + colorVisionType: (json['colorVisionType'] as String?) ?? 'none', + mapTilesEnabled: (json['mapTilesEnabled'] as bool?) ?? true, + coverageOverlayOpacity: + (json['coverageOverlayOpacity'] as num?)?.toDouble() ?? 0.7, + coverageGridSize: switch ((json['coverageGridSize'] as num?)?.toInt()) { + 100 => 100, + _ => 300, + }, + disconnectAlertEnabled: + (json['disconnectAlertEnabled'] as bool?) ?? false, + customApiEnabled: (json['customApiEnabled'] as bool?) ?? false, + customApiUrl: json['customApiUrl'] as String?, + customApiKey: json['customApiKey'] as String?, + customApiDisclaimerAccepted: + (json['customApiDisclaimerAccepted'] as bool?) ?? false, + customApiIncludeContact: + (json['customApiIncludeContact'] as bool?) ?? true, ); } + /// Migrate the legacy 'pacman' gps marker id to 'chomper' after the rename. + static String _migrateGpsMarkerStyle(String? value) { + if (value == null) return 'arrow'; + if (value == 'pacman') return 'chomper'; + return value; + } + /// Convert to JSON (for persistence) Map toJson() { return { @@ -156,7 +275,26 @@ class UserPreferences { 'mapRotationLocked': mapRotationLocked, 'disableRssiFilter': disableRssiFilter, 'anonymousMode': anonymousMode, + 'broadcastCoords': broadcastCoords, 'discDropEnabled': discDropEnabled, + 'floodTrafficEnabled': floodTrafficEnabled, + 'deleteChannelOnDisconnect': deleteChannelOnDisconnect, + 'minPingDistanceMeters': minPingDistanceMeters, + 'autoStopAfterIdle': autoStopAfterIdle, + 'showTopRepeaters': showTopRepeaters, + 'liveActivityShowNames': liveActivityShowNames, + 'markerStyle': markerStyle, + 'gpsMarkerStyle': gpsMarkerStyle, + 'colorVisionType': colorVisionType, + 'mapTilesEnabled': mapTilesEnabled, + 'coverageOverlayOpacity': coverageOverlayOpacity, + 'coverageGridSize': coverageGridSize, + 'disconnectAlertEnabled': disconnectAlertEnabled, + 'customApiEnabled': customApiEnabled, + 'customApiUrl': customApiUrl, + 'customApiKey': customApiKey, + 'customApiDisclaimerAccepted': customApiDisclaimerAccepted, + 'customApiIncludeContact': customApiIncludeContact, }; } @@ -185,7 +323,26 @@ class UserPreferences { bool? mapRotationLocked, bool? disableRssiFilter, bool? anonymousMode, + bool? broadcastCoords, bool? discDropEnabled, + bool? floodTrafficEnabled, + bool? deleteChannelOnDisconnect, + int? minPingDistanceMeters, + bool? autoStopAfterIdle, + bool? showTopRepeaters, + bool? liveActivityShowNames, + String? markerStyle, + String? gpsMarkerStyle, + String? colorVisionType, + bool? mapTilesEnabled, + double? coverageOverlayOpacity, + int? coverageGridSize, + bool? disconnectAlertEnabled, + bool? customApiEnabled, + String? customApiUrl, + String? customApiKey, + bool? customApiDisclaimerAccepted, + bool? customApiIncludeContact, }) { return UserPreferences( powerLevel: powerLevel ?? this.powerLevel, @@ -199,10 +356,12 @@ class UserPreferences { powerLevelSet: powerLevelSet ?? this.powerLevelSet, offlineMode: offlineMode ?? this.offlineMode, iataCode: iataCode ?? this.iataCode, - backgroundModeEnabled: backgroundModeEnabled ?? this.backgroundModeEnabled, + backgroundModeEnabled: + backgroundModeEnabled ?? this.backgroundModeEnabled, developerModeEnabled: developerModeEnabled ?? this.developerModeEnabled, mapStyle: mapStyle ?? this.mapStyle, - closeAppAfterDisconnect: closeAppAfterDisconnect ?? this.closeAppAfterDisconnect, + closeAppAfterDisconnect: + closeAppAfterDisconnect ?? this.closeAppAfterDisconnect, themeMode: themeMode ?? this.themeMode, unitSystem: unitSystem ?? this.unitSystem, hybridModeEnabled: hybridModeEnabled ?? this.hybridModeEnabled, @@ -211,7 +370,33 @@ class UserPreferences { mapRotationLocked: mapRotationLocked ?? this.mapRotationLocked, disableRssiFilter: disableRssiFilter ?? this.disableRssiFilter, anonymousMode: anonymousMode ?? this.anonymousMode, + broadcastCoords: broadcastCoords ?? this.broadcastCoords, discDropEnabled: discDropEnabled ?? this.discDropEnabled, + floodTrafficEnabled: floodTrafficEnabled ?? this.floodTrafficEnabled, + deleteChannelOnDisconnect: + deleteChannelOnDisconnect ?? this.deleteChannelOnDisconnect, + minPingDistanceMeters: + minPingDistanceMeters ?? this.minPingDistanceMeters, + autoStopAfterIdle: autoStopAfterIdle ?? this.autoStopAfterIdle, + showTopRepeaters: showTopRepeaters ?? this.showTopRepeaters, + liveActivityShowNames: + liveActivityShowNames ?? this.liveActivityShowNames, + markerStyle: markerStyle ?? this.markerStyle, + gpsMarkerStyle: gpsMarkerStyle ?? this.gpsMarkerStyle, + colorVisionType: colorVisionType ?? this.colorVisionType, + mapTilesEnabled: mapTilesEnabled ?? this.mapTilesEnabled, + coverageOverlayOpacity: + coverageOverlayOpacity ?? this.coverageOverlayOpacity, + coverageGridSize: coverageGridSize ?? this.coverageGridSize, + disconnectAlertEnabled: + disconnectAlertEnabled ?? this.disconnectAlertEnabled, + customApiEnabled: customApiEnabled ?? this.customApiEnabled, + customApiUrl: customApiUrl ?? this.customApiUrl, + customApiKey: customApiKey ?? this.customApiKey, + customApiDisclaimerAccepted: + customApiDisclaimerAccepted ?? this.customApiDisclaimerAccepted, + customApiIncludeContact: + customApiIncludeContact ?? this.customApiIncludeContact, ); } @@ -237,6 +422,9 @@ class UserPreferences { return '$autoPingInterval seconds'; } + /// Get min ping distance display string + String get minPingDistanceDisplay => '${minPingDistanceMeters}m'; + @override bool operator ==(Object other) { if (identical(this, other)) return true; @@ -263,7 +451,25 @@ class UserPreferences { other.mapRotationLocked == mapRotationLocked && other.disableRssiFilter == disableRssiFilter && other.anonymousMode == anonymousMode && - other.discDropEnabled == discDropEnabled; + other.broadcastCoords == broadcastCoords && + other.discDropEnabled == discDropEnabled && + other.floodTrafficEnabled == floodTrafficEnabled && + other.deleteChannelOnDisconnect == deleteChannelOnDisconnect && + other.minPingDistanceMeters == minPingDistanceMeters && + other.autoStopAfterIdle == autoStopAfterIdle && + other.showTopRepeaters == showTopRepeaters && + other.liveActivityShowNames == liveActivityShowNames && + other.markerStyle == markerStyle && + other.gpsMarkerStyle == gpsMarkerStyle && + other.colorVisionType == colorVisionType && + other.mapTilesEnabled == mapTilesEnabled && + other.coverageOverlayOpacity == coverageOverlayOpacity && + other.disconnectAlertEnabled == disconnectAlertEnabled && + other.customApiEnabled == customApiEnabled && + other.customApiUrl == customApiUrl && + other.customApiKey == customApiKey && + other.customApiDisclaimerAccepted == customApiDisclaimerAccepted && + other.customApiIncludeContact == customApiIncludeContact; } @override @@ -291,7 +497,25 @@ class UserPreferences { mapRotationLocked, disableRssiFilter, anonymousMode, + broadcastCoords, discDropEnabled, + floodTrafficEnabled, + deleteChannelOnDisconnect, + minPingDistanceMeters, + autoStopAfterIdle, + showTopRepeaters, + liveActivityShowNames, + markerStyle, + gpsMarkerStyle, + colorVisionType, + mapTilesEnabled, + coverageOverlayOpacity, + disconnectAlertEnabled, + customApiEnabled, + customApiUrl, + customApiKey, + customApiDisclaimerAccepted, + customApiIncludeContact, ]); } @@ -326,3 +550,8 @@ class AutoPingInterval { static const List values = [fast, normal, slow]; } + +/// Minimum ping distance (meters) +class MinPingDistance { + static const int min = 25; +} diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 51c9854b..8e600f77 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1,12 +1,17 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; +import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart' show SystemNavigator; -import 'package:flutter/widgets.dart' show WidgetsBinding, WidgetsBindingObserver, AppLifecycleState; +import 'package:flutter/widgets.dart' + show WidgetsBinding, WidgetsBindingObserver, AppLifecycleState; import 'package:geolocator/geolocator.dart'; import 'package:hive_flutter/hive_flutter.dart'; +import 'package:app_links/app_links.dart'; import 'package:uuid/uuid.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart' show SharePlus, ShareParams, XFile; import '../models/connection_state.dart'; @@ -18,25 +23,52 @@ import '../models/remembered_device.dart'; import '../models/repeater.dart'; import '../models/user_preferences.dart'; import '../services/api_queue_service.dart'; +import '../utils/mvt_cells.dart'; import '../services/api_service.dart'; import '../services/audio_service.dart'; import '../services/background_service.dart'; import '../services/debug_file_logger.dart'; import '../services/offline_session_service.dart'; +import '../services/bluetooth/ble_connect_retry_policy.dart'; import '../services/bluetooth/bluetooth_service.dart'; +import '../services/transport/android_serial_service.dart'; +import '../services/transport/companion_transport.dart'; +import '../services/transport/tcp_service.dart'; import '../services/device_model_service.dart'; import '../services/gps_service.dart'; import '../services/gps_simulator_service.dart'; +import '../services/link_decision.dart'; import '../services/meshcore/channel_service.dart'; import '../services/meshcore/connection.dart'; import '../services/meshcore/crypto_service.dart'; -import '../services/meshcore/packet_validator.dart' show PacketValidator, ChannelInfo; +import '../services/meshcore/packet_validator.dart' + show PacketValidator, ChannelInfo; import '../services/meshcore/rx_logger.dart'; import '../services/meshcore/tx_tracker.dart'; import '../services/meshcore/unified_rx_handler.dart'; import '../services/ping_service.dart'; import '../services/countdown_timer_service.dart'; +import '../services/app_intents/app_intent_bridge_service.dart'; +import '../services/app_intents/app_intent_commands.dart'; +import '../services/app_intents/last_companion_connection.dart'; +import '../services/app_intents/siri_snapshot_builder.dart'; +import '../services/app_intents/siri_snapshot_models.dart'; +import '../services/external_commands/external_command_models.dart'; +import '../services/external_commands/external_session_commands.dart'; +import '../services/external_surfaces/geo/external_surface_geo_builder.dart'; +import '../services/live_activity/live_activity_models.dart'; +import '../services/live_activity/live_activity_service.dart'; +import '../services/auto/android_auto_service.dart'; +import '../services/auto/car_map_channel.dart'; +import '../services/watch/watch_bridge_service.dart'; +import '../widgets/map_widget.dart' + show MapStyleExtension, gpsMarkerFacesHeading; +import '../services/watch/watch_models.dart'; +import '../services/custom_api_service.dart'; +import '../services/portal_account_service.dart'; import '../utils/constants.dart'; +import '../utils/geo_validation.dart'; +import '../utils/ping_colors.dart'; import '../services/wakelock_service.dart'; import '../utils/debug_logger_io.dart'; @@ -44,24 +76,61 @@ import '../utils/debug_logger_io.dart'; enum AutoMode { /// Active Mode: Sends pings on movement, listens for RX responses active, + /// Passive Mode: Listening only (no transmit) passive, + /// Hybrid Mode: Alternates Discovery + Active pings each interval hybrid, + + /// Trace Mode: Zero-hop trace to specific repeater + targeted; + + /// What the app's own buttons call this mode, minus the trailing "Mode". + /// + /// The wire name is not speakable: the UI has never called `targeted` + /// anything but Trace, so a spoken sentence must not leak the enum name. + String get displayName => switch (this) { + AutoMode.active => 'Active', + AutoMode.passive => 'Passive', + AutoMode.hybrid => 'Hybrid', + AutoMode.targeted => 'Trace', + }; } +/// Ping type for the top-heard overlay dots +enum OverlayPingType { tx, disc, trace, rx } + +enum _LiveActivityOperation { sending, discovering, tracing } + /// Result of uploading an offline session enum OfflineUploadResult { /// Upload completed successfully success, + /// Session file not found notFound, + /// Session data is invalid or empty invalidSession, - /// API authentication failed + + /// API authentication failed (device not registered / genuine rejection) authFailed, + + /// Network/timeout error reaching the API (not an auth rejection) — retryable + networkError, + /// Some pings failed to upload partialFailure, + + /// Another upload is already in progress + uploadInProgress, + + /// GPS position required but not available + gpsRequired, + + /// Zone is disabled server-side + zoneDisabled, } /// Main application state provider @@ -77,12 +146,76 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { late final ApiQueueService _apiQueueService; late final OfflineSessionService _offlineSessionService; late final DeviceModelService _deviceModelService; + late final CustomApiService _customApiService; final AudioService _audioService = AudioService(); - late final CooldownTimer _cooldownTimer; // Shared cooldown for TX Ping and Active Mode - late final ManualPingCooldownTimer _manualPingCooldownTimer; // Manual ping cooldown (15 seconds) + late final CooldownTimer + _cooldownTimer; // Shared cooldown for TX Ping and Active Mode + late final ManualPingCooldownTimer + _manualPingCooldownTimer; // Manual ping cooldown (15 seconds) late final AutoPingTimer _autoPingTimer; late final RxWindowTimer _rxWindowTimer; - late final DiscoveryWindowTimer _discoveryWindowTimer; // Discovery listening window (Passive Mode) + late final DiscoveryWindowTimer + _discoveryWindowTimer; // Discovery listening window (Passive Mode) + late final Listenable _timerListenable; + + /// Whether [_timerListenable] has been assigned *and* had the Live Activity + /// listener attached. + /// + /// `late final` throws on read before assignment, and the assignment happens + /// partway through [initialize]. A provider torn down before that point — + /// a widget test, or an early init failure — would otherwise throw on the + /// first line of [dispose] and skip every cancel below it. + bool _timerListenerAttached = false; + + final LiveActivityService _liveActivityService = LiveActivityService(); + final WatchBridgeService _watchBridge = WatchBridgeService(); + final AppIntentBridgeService _appIntentBridge = AppIntentBridgeService(); + final AndroidAutoService _androidAuto = AndroidAutoService(); + bool _hasEverPairedWatch = false; + + /// Last position handed to the watch, kept so a dropped GPS fix leaves the + /// puck where it was rather than removing it. See [_resolveWatchPosition]. + WatchPosition? _lastWatchPosition; + + /// Held position behind every distance and ordering decision in the payload. + /// See [_resolveRankingPosition] for why this one still lags on purpose. + ({double lat, double lon})? _rankingPosition; + WatchHapticCue? _watchCue; + + /// The pending cue, for as long as the watch would still present it. + /// + /// **Age, not delivery.** This used to be dropped the moment a snapshot + /// carrying it came back from native — but that reply means + /// `updateApplicationContext` accepted the blob, not that the watch ingested + /// it, and the wearer's wrist is usually down at exactly that moment. Since + /// the cue ID sits in the urgency key, the very next flush was urgent and + /// overwrote the retained context with a cue-less payload, often within a + /// second. The watch woke to idle UI and no account of the failure it had + /// just been told about — the failure path having quietly deleted its own + /// only evidence. + /// + /// Re-attaching costs nothing and cannot double-buzz: the watch keys its + /// haptics on `presentedCueIDs`, and past [WatchWire.cueReadableFor] it + /// drops the cue itself rather than asserting a dead failure as current. + WatchHapticCue? get _presentableWatchCue { + final cue = _watchCue; + if (cue == null) return null; + return cue.isPresentableAt(DateTime.now()) ? cue : null; + } + + /// Human-readable failure from the most recent server-side session check. + /// The bool returned by that check controls the action; this preserves the + /// discarded explanation for a wrist action's later failure cue. + ExternalCommandReason? _lastSessionCheckFailureReason; + bool _liveActivitySessionActive = false; + bool _liveActivityManualSession = false; + String? _liveActivitySessionId; + + /// When the current session began, so read-only surfaces can say "this + /// session" and mean it rather than "whatever is in the recent history". + DateTime? _liveActivitySessionStartedAt; + DateTime? _liveActivityCycleStartedAt; + _LiveActivityOperation? _liveActivityOperation; MeshCoreConnection? _meshCoreConnection; PingService? _pingService; UnifiedRxHandler? _unifiedRxHandler; @@ -92,6 +225,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { StreamSubscription? _noiseFloorSubscription; StreamSubscription? _batterySubscription; + // Transport selection + TransportType _selectedTransport = TransportType.ble; + CompanionTransport? _activeTransport; + StreamSubscription? _transportConnectionSubscription; + // Device identity String _deviceId = ''; @@ -99,26 +237,35 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ConnectionStatus _connectionStatus = ConnectionStatus.disconnected; ConnectionStep _connectionStep = ConnectionStep.disconnected; String? _connectionError; - bool _isAuthError = false; // Track if connection failed due to auth + bool _isAuthError = false; // Track if connection failed due to auth + bool _isNetworkError = false; // Track if connection failed due to network // Bluetooth adapter state (on/off) BluetoothAdapterState _bluetoothAdapterState = BluetoothAdapterState.unknown; StreamSubscription? _adapterStateSubscription; + StreamSubscription? _connectionSubscription; + StreamSubscription? _gpsStatusSubscription; + StreamSubscription? _gpsPositionSubscription; // GPS state GpsStatus _gpsStatus = GpsStatus.permissionDenied; Position? _currentPosition; ({double lat, double lon})? _lastKnownPosition; - DateTime? _lastPositionSaveTime; // Throttle position saves to every 30 seconds - bool _firstGpsLockLogged = false; // Track if we've logged first GPS lock message + DateTime? + _lastPositionSaveTime; // Throttle position saves to every 30 seconds + bool _firstGpsLockLogged = + false; // Track if we've logged first GPS lock message // Device info DeviceModel? _deviceModel; String? _manufacturerString; + String? _firmwareVersionString; String? _devicePublicKey; + String? _offlineContactUri; - /// BLE device name (e.g., "MeshCore-MrAlders0n_Elecrow") - String? get connectedDeviceName => _bluetoothService.connectedDevice?.name; + /// Connected device name (e.g., "MeshCore-MrAlders0n_Elecrow" for BLE, "TCP 10.0.0.1:5000" for TCP) + String? get connectedDeviceName => + (_activeTransport ?? _bluetoothService).connectedDevice?.name; /// Display name from SelfInfo (reflects user's chosen name in MeshCore) /// BLE advertisement name may be cached/stale after device rename @@ -126,13 +273,26 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// The device name to display (prefers SelfInfo name over BLE advertisement name) /// SelfInfo name reflects user's chosen name in MeshCore; BLE name may be cached/stale - String? get displayDeviceName => _displayDeviceName ?? connectedDeviceName?.replaceFirst('MeshCore-', ''); + String? get displayDeviceName => + _displayDeviceName ?? connectedDeviceName?.replaceFirst('MeshCore-', ''); // Ping state PingStats _pingStats = const PingStats(); bool _autoPingEnabled = false; AutoMode _autoMode = AutoMode.active; + DateTime? _idleAutoStopReference; + static const Duration _autoStopIdleTimeout = Duration(minutes: 30); + // Anchor walk backing the idle auto-stop, mirroring the server's stationary + // guard (stationary_guard.php sg_walk, SG_RADIUS_M / SG_STREAK_N) so a polite + // client stops itself before the server revokes the session. + double? _idleAnchorLat; + double? _idleAnchorLon; + int _idleAnchorEscapeStreak = 0; + static const double _idleAnchorRadiusMeters = 150; + static const int _idleAnchorStreakRequired = 3; bool _isPingSending = false; // True immediately when ping button clicked + bool _autoPingStarting = + false; // True while an auto mode is starting (before the first notify) int _queueSize = 0; int? _currentNoiseFloor; int? _currentBatteryPercent; @@ -154,6 +314,39 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final List _txLogEntries = []; final List _rxLogEntries = []; final List _discLogEntries = []; + final List _traceLogEntries = []; + int _siriObservationRevision = 0; + + // Top repeaters overlay — updated live on each ping event + List<({String repeaterId, double snr, OverlayPingType type})> + _topRepeatersOverlay = []; + DateTime? _topRepeatersOverlayUpdatedAt; + + /// The fullest identity known for each overlay row, keyed by the display hash + /// the row is shown under. + /// + /// Discovery responses carry the responder's full 64-character public key and + /// traces carry their 4-byte target, so for those rows the phone knows + /// exactly who answered. TX echoes and passive RX carry only the path byte, + /// and are absent here because nothing better exists for them. + /// + /// Replaced wholesale by [_updateTopRepeaters], never merged, and for the + /// same reason those slots are: a hash that meant one repeater in a discovery + /// response says nothing about who a later TX echo under the same hash was. + Map _overlayIdentityById = const {}; + ({String repeaterId, double snr})? _rxOverlaySlot; + Timer? _rxOverlayWindowTimer; + + // Live Activity repeater snapshot. Kept separate from the map overlay so the + // system presentation cannot change existing in-app overlay behaviour. + List<({String repeaterId, double snr, OverlayPingType type})> + _liveActivityRepeaters = []; + int _liveActivityRepeaterTotalCount = 0; + DateTime? _liveActivityRepeatersUpdatedAt; + DateTime? _liveActivityRxUpdatedAt; + + // Targeted mode state + String? _targetRepeaterId; // User error log entries final List _errorLogEntries = []; @@ -162,8 +355,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { UserPreferences _preferences = const UserPreferences(); // Anonymous mode state - String? _originalDeviceName; // Real name stored before rename - bool _isAnonymousRenamed = false; // Device currently renamed to "Anonymous" + String? _originalDeviceName; // Real name stored before rename + bool _isAnonymousRenamed = false; // Device currently renamed to "Anonymous" + + /// Per-device real name persistence: maps device public key → real device name. + /// Survives unexpected BLE disconnects where setAdvertName restore can't run. + Map _deviceRealNames = {}; /// Per-device antenna preferences: maps companion name → external antenna bool Map _deviceAntennaPreferences = {}; @@ -172,8 +369,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool _antennaRestoredFromDevice = false; bool get antennaRestoredFromDevice => _antennaRestoredFromDevice; + /// Per-device power overrides: maps companion name → {powerLevel, txPower} + Map> _devicePowerOverrides = {}; + + /// Whether the current power setting was auto-restored from a saved override + bool _powerRestoredFromDevice = false; + bool get powerRestoredFromDevice => _powerRestoredFromDevice; + // Remembered device for quick reconnection (mobile only) RememberedDevice? _rememberedDevice; + final Completer _rememberedDeviceReady = Completer(); + + // User's original preferences before zone admin overrides (single baseline). + // Saved on initial connect; restored before applying each new zone's policies. + int? _userOriginalAutoPingInterval; + bool? _userOriginalHybridMode; + bool? _userOriginalDiscDrop; + bool? _userOriginalFloodTraffic; // Debug logs state (non-persistent, always starts false) bool _debugLogsEnabled = false; @@ -192,11 +404,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool _isCheckingZone = false; // Zone check retry state - String? _zoneCheckError; // Error message from last failed check (null = no error) - String? _zoneCheckErrorReason; // 'network', 'gps_inaccurate', 'gps_stale', 'server_error' - int _zoneCheckRetryCountdown = 0; // Seconds until next retry (0 = not counting) - Timer? _zoneCheckRetryTimer; // Fires to trigger the retry - Timer? _zoneCheckCountdownTimer; // Ticks every 1s for UI countdown + String? + _zoneCheckError; // Error message from last failed check (null = no error) + String? + _zoneCheckErrorReason; // 'network', 'gps_inaccurate', 'gps_stale', 'server_error' + int _zoneCheckRetryCountdown = + 0; // Seconds until next retry (0 = not counting) + Timer? _zoneCheckRetryTimer; // Fires to trigger the retry + Timer? _zoneCheckCountdownTimer; // Ticks every 1s for UI countdown // Maintenance mode state bool _maintenanceMode = false; @@ -204,9 +419,22 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String? _maintenanceUrl; Timer? _maintenanceCheckTimer; - // Tile refresh after upload - int _overlayCacheBust = 0; - Timer? _tileRefreshTimer; + // Post-wardrive tile refresh: coords of recently uploaded pings (with the + // zone they belong to) and the +7s fresh-fetch timer (one retry at +10s). + // See VECTOR_TILES.md "Flutter post-wardrive live refresh". + Timer? _vectorFreshTimer; + final List> _pendingFreshCoords = []; // [lat, lon] + String? _pendingFreshZone; + bool _vectorOverlayActive = false; + + // Session coverage patch: the user's own freshly-pinged cells, drawn by the + // MapWidget as a small GeoJSON layer ON TOP of the base overlay (whose + // copies of these ids are filtered out, so translucent fills never stack). + // The base source is never swapped/cache-busted during a session — nothing + // may visibly change except the cells that actually changed. Keyed by + // feature id, insertion-ordered, capped. + final Map _coveragePatchCells = {}; + int _coveragePatchVersion = 0; // Auth type from API response (API, Mesh, Manual) String? _authType; @@ -215,29 +443,140 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool _isSwitchingMode = false; String? _modeSwitchError; // Error message if mode switch fails + // Connection guard — prevents concurrent connect attempts and provides instant UI feedback + bool _isConnecting = false; + // Auto-reconnect state bool _userRequestedDisconnect = false; + + /// Bumped by every transport connect entry point. A connection attempt that + /// finds its captured generation stale has been superseded by a newer + /// attempt and must not touch shared provider state (#500 review). + int _connectGeneration = 0; bool _isAutoReconnecting = false; + + // ============================================ + // MyMeshMapper account + companion linking + // ============================================ + late final PortalAccountService _portalAccountService; + + /// Cached portal identity (mirrors Hive `portal_account_info`). + PortalAccount? _portalAccount; + + /// UPPER 64-hex pubkeys this account owns (mirrors `portal_linked_pubkeys`). + List _portalLinkedPubkeys = []; + + /// UPPER pubkey -> declined. Persisted; survives sign-out (device pref). + Map _portalLinkDeclinedDevices = {}; + + /// UPPER pubkey -> firmware cannot sign. Persisted; survives sign-out. + Map _portalSignUnsupportedDevices = {}; + + /// Prompted once per pubkey per APP SESSION (not per connection) — a BLE flap + /// plus auto-reconnect must never re-ask mid-drive. + final Set _portalPromptedThisSession = {}; + + // Backing field for [portalLinkPromptPending]. Not final: the link flow + // sets it when a radio needs the one-tap prompt. + bool _portalLinkPromptPending = false; + + /// Sanitized failure code from the last sign-in attempt, waiting to be shown. + /// + /// The browser round trip outlives the Settings tap that started it, so the + /// service's completion callback is the ONLY place a failure can surface — + /// `MainScaffold` drains this and clears it. Deliberately holds the CODE, not + /// the copy: the UI layer owns the user-facing wording, same as + /// `PortalLinkStatus`. + String? _portalSignInError; + + /// The pubkey the pending prompt belongs to. Captured when the prompt is + /// raised so a mid-prompt reconnect cannot link the wrong radio. + String? _portalLinkPromptPubkey; + + /// UPPER pubkey -> consecutive link failures. Drives the retry backoff and + /// the attempt cap. In-memory only — a restart is allowed a clean slate. + final Map _portalLinkAttempts = {}; + + /// UPPER pubkey -> earliest time the next link attempt may run. + final Map _portalLinkRetryAfter = {}; + + /// Give up offering after this many failed attempts for one pubkey. + static const int _portalLinkMaxAttempts = 5; + + /// UPPER pubkey -> how many times CMD_SIGN_START answered ERR. In-memory: + /// two strikes are required before the verdict is persisted (see + /// [_performDeviceLink]). Cleared the moment the radio signs successfully. + final Map _portalUnsupportedStrikes = {}; + + /// UPPER pubkey -> how many times the server rejected our signature. Kept + /// separate from [_portalLinkAttempts] on purpose: that counter also holds + /// nonce and network failures, which say nothing about what the radio signs. + final Map _portalBadSignatureStrikes = {}; + + static const String _portalAccountKey = 'portal_account_info'; + static const String _portalLinkedPubkeysKey = 'portal_linked_pubkeys'; + static const String _portalDeclinedKey = 'portal_link_declined_devices'; + static const String _portalSignUnsupportedKey = + 'portal_sign_unsupported_devices'; + int _reconnectAttempt = 0; Timer? _reconnectTimer; Timer? _reconnectTimeoutTimer; + Timer? _restoreAutoPingTimer; + Timer? _offlineAutoSaveTimer; + Timer? _zoneRefreshTimer; bool _autoPingWasEnabled = false; AutoMode _autoModeBeforeReconnect = AutoMode.active; + int _reconnectRestoreGeneration = 0; static const int _maxReconnectAttempts = 3; static const Duration _reconnectDelay = Duration(seconds: 3); + static const Duration _reconnectDelayAfterBondError = Duration(seconds: 5); + bool _lastReconnectWasBondError = false; + + // Idle disconnect timer — disconnects after 15 min without manual ping or auto-ping + Timer? _idleDisconnectTimer; + static const Duration _idleDisconnectTimeout = Duration(minutes: 15); + + // Zone grace period — pauses wardriving when outside_zone, resumes on zone re-entry + bool _isInZoneGracePeriod = false; + Timer? _zoneGraceTimer; // 5-minute overall timeout + Timer? _zoneGracePollingTimer; // 5-second zone polling + Timer? _zoneGraceCountdownTimer; // 1-second UI countdown tick + int _zoneGraceSecondsRemaining = 0; + DateTime? _zoneGraceEndsAt; + bool _autoPingWasEnabledBeforeGrace = false; + AutoMode _autoModeBeforeGrace = AutoMode.active; + static const Duration _zoneGraceTimeout = Duration(minutes: 5); + + // Zone transfer state — tracks session zone for zone-to-zone detection + String? _sessionZoneCode; + bool _isZoneTransferInProgress = false; + String? _zoneTransferFrom; + String? _zoneTransferTo; + + // Geofence zone check log throttle (while disconnected) + DateTime? _lastZoneCheckLogTime; + int _zoneCheckSuppressedCount = 0; // Map navigation trigger (for navigating to log entry coordinates) ({double lat, double lon})? _mapNavigationTarget; int _mapNavigationTrigger = 0; // Increment to trigger navigation bool _requestMapTabSwitch = false; // Request switch to map tab bool _requestErrorLogSwitch = false; // Request switch to error log tab - bool _requestConnectionTabSwitch = false; // Request switch to connection tab + bool _isAnonymousReconnectInProgress = false; + bool _anonymousReconnectEnabling = true; // Repeater markers state List _repeaters = []; + int _siriRepeaterCatalogRevision = 0; bool _repeatersLoaded = false; String? _repeatersLoadedForIata; + // Regional boundary polygons (from /border API — always displayed on map) + List> _regionBorders = []; + String? _bordersLoadedForZone; + bool _bordersFetchInProgress = false; + // Regional channels from API (for UI display) List _regionalChannels = []; @@ -245,15 +584,24 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String? _scope; // Path hash mode tracking (for multi-byte path support) - int? _originalPathHashMode; // Device's mode BEFORE we changed it (from DeviceInfo) - bool _userChangedPathMode = false; // True if user manually changed hopBytes while connected - int _hopBytes = 1; // Runtime-only: current hop byte size (read from device, not persisted) + int? + _originalPathHashMode; // Device's mode BEFORE we changed it (from DeviceInfo) + bool _userChangedPathMode = + false; // True if user manually changed hopBytes while connected + int _hopBytes = + 1; // Runtime-only: current hop byte size (read from device, not persisted) + int _traceHopBytes = + 1; // Runtime-only: trace byte size (1, 2, or 4 — bitshift encoding) // Noise floor session tracking (for graph feature) NoiseFloorSession? _currentNoiseFloorSession; List _storedNoiseFloorSessions = []; Box? _noiseFloorSessionBox; + // History session map view + List? _historySessionMarkers; + bool _viewingHistorySession = false; + // Flag to track if preferences have been loaded from storage bool _preferencesLoaded = false; @@ -270,38 +618,180 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { debugLog('[APP] App resumed from background'); + _checkTcpHealthAfterResume(); + // Diagnostic: timers can be suspended while backgrounded; on resume, log + // any countdown timer stuck past its deadline (intermittent ping lockout). + _logStuckTimers('resume'); + // iOS can deliver the portal callback while the isolate is suspended. + unawaited(_portalAccountService.checkLinkOnResume()); + } else if (state == AppLifecycleState.paused) { + debugLog('[APP] App paused (backgrounded)'); + // Save offline pings immediately on pause to prevent data loss if OS kills app + if (_preferences.offlineMode && _apiQueueService.offlinePingCount > 0) { + _autoSaveOfflinePings(); + } + } else if (state == AppLifecycleState.detached) { + // The engine now outlives MainActivity (see MeshMapperEngine), so the + // Activity going away no longer tears anything down on its own. Swiping + // the app from Recents with no session running used to end the process and + // with it the BLE link; it now leaves the radio connected to a device + // nobody is watching, draining both batteries until something else + // disconnects it. + // + // Deliberately conditional on there being no session: a wardriving run is + // pinned by flutter_background_service's foreground service and is + // *expected* to outlive the UI — that is the whole point of it, and of the + // Android Auto surface that can now drive it with no Activity at all. + if (!_autoPingEnabled) { + debugLog('[APP] Detached with no session — releasing connection'); + unawaited(disconnect()); + } else { + debugLog('[APP] Detached during a session — keeping connection'); + } + } + } + + /// Probe TCP connection after iOS resume — socket may have died while suspended. + Future _checkTcpHealthAfterResume() async { + if (_selectedTransport != TransportType.tcp) return; + if (_connectionStep != ConnectionStep.connected) return; + if (_isAutoReconnecting || _userRequestedDisconnect) return; + + // Let pending socket error/done events propagate first + await Future.delayed(const Duration(milliseconds: 1500)); + + // If socket events already triggered auto-reconnect, nothing to do + if (_connectionStep != ConnectionStep.connected) return; + if (_isAutoReconnecting) return; + + debugLog('[CONN] Probing TCP connection after resume'); + try { + await _meshCoreConnection!.getNoiseFloor(); + debugLog('[CONN] TCP connection healthy after resume'); + } catch (e) { + debugLog('[CONN] TCP probe failed after resume: $e'); + if (_connectionStep == ConnectionStep.connected && + !_isAutoReconnecting && + _rememberedDevice != null && + !_userRequestedDisconnect) { + await _startAutoReconnect(); + } + } + } + + // Throttle for the stuck-timer diagnostic (driven off the ~1-2Hz GPS notify). + DateTime? _lastStuckTimerCheck; + + /// Diagnostic ONLY (no state change, no notify): logs any countdown timer that + /// still reports `isRunning` after its deadline has passed (`remainingMs == 0`). + /// That is the fingerprint of the intermittent "Send Ping locks out + /// Hybrid/Passive" lockout — a [CountdownTimerService] whose 500ms `_update()` + /// stopped firing (e.g. iOS suspended its timers while backgrounded/driving) + /// never self-cancels, so `isRunning` (`_timer != null`) sticks true and keeps + /// the ping controls disabled until a force-close. A stuck `rxWindowTimer` + /// additionally disables Send Ping itself, so the user can't ping to reset it. + /// This logging is here to capture the real trigger on-device; the actual fix + /// is deferred until a debug log confirms it. See countdown_timer_service.dart. + void _logStuckTimers(String reason) { + void check(String name, CountdownTimerService t) { + if (t.isRunning && t.remainingMs == 0) { + debugWarn( + '[TIMER] $name isRunning past its deadline (stuck, remaining=0) — ' + 'locks ping controls until restart [$reason]'); + } + } + + check('rxWindowTimer', _rxWindowTimer); + check('cooldownTimer', _cooldownTimer); + check('manualPingCooldownTimer', _manualPingCooldownTimer); + check('discoveryWindowTimer', _discoveryWindowTimer); + check('autoPingTimer', _autoPingTimer); + + // pendingDisable should clear when the RX/discovery window it is waiting on + // completes. Still true while no such window is counting down => stuck. + if (isPendingDisable && + !_rxWindowTimer.isRunning && + !_discoveryWindowTimer.isRunning) { + debugWarn('[TIMER] pendingDisable stuck true with no RX/discovery window ' + 'running — locks ping controls until restart [$reason]'); } } + /// Runs [_logStuckTimers] at most once every 5s. Called from the GPS position + /// notify (~1-2Hz during wardriving) so the stuck condition is caught in the + /// foreground without adding a dedicated timer. Connected-only to avoid noise. + void _maybeLogStuckTimers() { + if (!isConnected) return; + final now = DateTime.now(); + final last = _lastStuckTimerCheck; + if (last != null && now.difference(last).inSeconds < 5) return; + _lastStuckTimerCheck = now; + _logStuckTimers('watchdog'); + } + // ============================================ // Getters // ============================================ String get deviceId => _deviceId; bool get preferencesLoaded => _preferencesLoaded; + WatchBridgeService get watchBridge => _watchBridge; + bool get shouldShowWatchDiagnostics { + final status = _watchBridge.diagnostics.value; + return resolveShouldShowWatchDiagnostics( + isSupportedPlatform: _watchBridge.isSupportedPlatform, + supported: status.supported, + paired: status.paired, + activated: status.activated, + hasEverPaired: _hasEverPairedWatch, + ); + } + + TransportType get selectedTransport => _selectedTransport; ConnectionStatus get connectionStatus => _connectionStatus; ConnectionStep get connectionStep => _connectionStep; String? get connectionError => _connectionError; bool get isAuthError => _isAuthError; + bool get isNetworkError => _isNetworkError; BluetoothAdapterState get bluetoothAdapterState => _bluetoothAdapterState; bool get isBluetoothOn => _bluetoothAdapterState == BluetoothAdapterState.on; - bool get isBluetoothOff => _bluetoothAdapterState == BluetoothAdapterState.off; + bool get isBluetoothOff => + _bluetoothAdapterState == BluetoothAdapterState.off; GpsStatus get gpsStatus => _gpsStatus; Position? get currentPosition => _currentPosition; ({double lat, double lon})? get lastKnownPosition => _lastKnownPosition; DeviceModel? get deviceModel => _deviceModel; String? get manufacturerString => _manufacturerString; + String? get firmwareVersionString => _firmwareVersionString; + + /// Human-readable radio config from the connected device's SelfInfo + /// (e.g. "910.525 MHz · 62.5 kHz · SF7 · CR5"); null on older firmware/no device. + String? get radioConfigDisplay => + _meshCoreConnection?.selfInfo?.radioConfigDisplay; String? get devicePublicKey => _devicePublicKey; PingStats get pingStats => _pingStats; bool get autoPingEnabled => _autoPingEnabled; AutoMode get autoMode => _autoMode; bool get isPingSending => _isPingSending; - bool get isPingInProgress => _pingService?.pingInProgress ?? false; // True during entire ping + RX window (for auto pings) - bool get isDiscoveryListening => _pingService?.isDiscoveryListening ?? false; // True during discovery listening window (for Passive Mode) + bool get isAutoPingStarting => _autoPingStarting; + bool get isPingInProgress => + _pingService?.pingInProgress ?? + false; // True during entire ping + RX window (for auto pings) + bool get isDiscoveryListening => + _pingService?.isDiscoveryListening ?? + false; // True during discovery listening window (for Passive Mode) /// Check if auto-ping disable is pending (waiting for RX window) bool get isPendingDisable => _pingService?.pendingDisable ?? false; + /// True when running any mode that does TX (Active or Hybrid) - bool get isTxModeRunning => _autoPingEnabled && (_autoMode == AutoMode.active || _autoMode == AutoMode.hybrid); + bool get isTxModeRunning => + _autoPingEnabled && + (_autoMode == AutoMode.active || _autoMode == AutoMode.hybrid); + + /// True when running Trace Mode (zero-hop trace) + bool get isTargetedModeRunning => + _autoPingEnabled && _autoMode == AutoMode.targeted; + String? get targetRepeaterId => _targetRepeaterId; int get queueSize => _queueSize; int? get currentNoiseFloor => _currentNoiseFloor; int? get currentBatteryPercent => _currentBatteryPercent; @@ -309,15 +799,142 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isScanning => _isScanning; List get txPings => List.unmodifiable(_txPings); List get rxPings => List.unmodifiable(_rxPings); + + /// Top 3 repeaters by best SNR from TX/DISC/Trace pings + List<({String repeaterId, double snr, OverlayPingType type})> + get topRepeatersBySnr => _topRepeatersOverlay; + + /// Best RX observation in the current 5-second window + ({String repeaterId, double snr})? get rxOverlaySlot => _rxOverlaySlot; + + /// Update the top repeaters overlay with results from the latest TX/DISC/Trace ping. + /// Replaces all 3 slots entirely (no carryover from previous pings). + /// - Parameter identities: display hash to the fullest identity this ping + /// carried for it, for the ping types that carry more than a path byte. + void _updateTopRepeaters( + List<({String repeaterId, double snr})> current, OverlayPingType type, + {Map? identities}) { + _overlayIdentityById = identities ?? const {}; + final bestSnr = {}; + for (final r in current) { + final key = r.repeaterId.toUpperCase(); + if (!bestSnr.containsKey(key) || r.snr > bestSnr[key]!) { + bestSnr[key] = r.snr; + } + } + final fresh = bestSnr.entries + .map((e) => (repeaterId: e.key, snr: e.value, type: type)) + .toList() + ..sort((a, b) => b.snr.compareTo(a.snr)); + _topRepeatersOverlay = fresh.take(3).toList(); + _topRepeatersOverlayUpdatedAt = DateTime.now(); + } + + void _updateLiveActivityRepeaters( + Iterable<({String repeaterId, double snr})> current, + OverlayPingType type) { + final bestSnr = {}; + for (final repeater in current) { + if (!repeater.snr.isFinite) continue; + final id = repeater.repeaterId.toUpperCase(); + final previous = bestSnr[id]; + if (previous == null || repeater.snr > previous) { + bestSnr[id] = repeater.snr; + } + } + + final sorted = bestSnr.entries + .map((entry) => (repeaterId: entry.key, snr: entry.value, type: type)) + .toList() + ..sort((a, b) => b.snr.compareTo(a.snr)); + _liveActivityRepeaters = sorted.take(3).toList(growable: false); + _liveActivityRepeaterTotalCount = sorted.length; + _liveActivityRepeatersUpdatedAt = DateTime.now(); + } + + /// Display hash to full public key, for the discovery nodes that published + /// one. Nodes whose hash is claimed by two different keys are left out rather + /// than resolved to whichever arrived last. + Map _discoveryIdentities(List nodes) { + final identities = {}; + final contested = {}; + for (final node in nodes) { + final pubkey = node.pubkeyHex; + if (pubkey == null || pubkey.isEmpty) continue; + final displayId = node.repeaterId.toUpperCase(); + final existing = identities[displayId]; + if (existing != null && existing != pubkey.toUpperCase()) { + contested.add(displayId); + continue; + } + identities[displayId] = pubkey.toUpperCase(); + } + for (final displayId in contested) { + identities.remove(displayId); + } + return identities; + } + + /// Update the RX overlay slot — window matches auto-ping interval (best SNR wins). + void _updateRxOverlaySlot(String repeaterId, double snr) { + final entry = (repeaterId: repeaterId.toUpperCase(), snr: snr); + if (_rxOverlayWindowTimer?.isActive ?? false) { + if (_rxOverlaySlot == null || snr > _rxOverlaySlot!.snr) { + _rxOverlaySlot = entry; + _liveActivityRxUpdatedAt = DateTime.now(); + } + } else { + _rxOverlaySlot = entry; + _liveActivityRxUpdatedAt = DateTime.now(); + _rxOverlayWindowTimer = + Timer(Duration(seconds: _preferences.autoPingInterval), () { + // Window closed — slot stays until next RX or cleared + }); + } + } + + /// Clear all overlay state (top 3 + RX slot). + void _clearOverlayState() { + _topRepeatersOverlay = []; + _topRepeatersOverlayUpdatedAt = null; + _overlayIdentityById = const {}; + _rxOverlaySlot = null; + _rxOverlayWindowTimer?.cancel(); + _rxOverlayWindowTimer = null; + _liveActivityRepeaters = []; + _liveActivityRepeaterTotalCount = 0; + _liveActivityRepeatersUpdatedAt = null; + _liveActivityRxUpdatedAt = null; + } + List get txLogEntries => List.unmodifiable(_txLogEntries); List get rxLogEntries => List.unmodifiable(_rxLogEntries); List get discLogEntries => List.unmodifiable(_discLogEntries); - List get errorLogEntries => List.unmodifiable(_errorLogEntries); + List get traceLogEntries => + List.unmodifiable(_traceLogEntries); + List get errorLogEntries => + List.unmodifiable(_errorLogEntries); + List get unifiedPingLogEntries { + final merged = [ + ..._txLogEntries.map((e) => UnifiedPingLogEntry( + type: PingLogType.tx, timestamp: e.timestamp, entry: e)), + ..._rxLogEntries.map((e) => UnifiedPingLogEntry( + type: PingLogType.rx, timestamp: e.timestamp, entry: e)), + ..._discLogEntries.map((e) => UnifiedPingLogEntry( + type: PingLogType.disc, timestamp: e.timestamp, entry: e)), + ..._traceLogEntries.map((e) => UnifiedPingLogEntry( + type: PingLogType.trace, timestamp: e.timestamp, entry: e)), + ]; + merged.sort(); + return merged; + } + ({double lat, double lon})? get mapNavigationTarget => _mapNavigationTarget; int get mapNavigationTrigger => _mapNavigationTrigger; bool get requestMapTabSwitch => _requestMapTabSwitch; bool get requestErrorLogSwitch => _requestErrorLogSwitch; - bool get requestConnectionTabSwitch => _requestConnectionTabSwitch; + bool get isAnonymousReconnectInProgress => _isAnonymousReconnectInProgress; + bool get anonymousReconnectEnabling => _anonymousReconnectEnabling; UserPreferences get preferences => _preferences; RememberedDevice? get rememberedDevice => _rememberedDevice; @@ -337,12 +954,215 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isCheckingZone => _isCheckingZone; String? get zoneName => _currentZone?['name'] as String?; String? get zoneCode => _currentZone?['code'] as String?; - int get overlayCacheBust => _overlayCacheBust; + + /// MapWidget handshake: whether the coverage overlay is currently on the + /// map. Gates the post-wardrive fresh-tile flow — no overlay, no fetches. + void reportVectorOverlayActive(bool active) { + _vectorOverlayActive = active; + } + + /// Bumped whenever the session coverage patch changes; the MapWidget + /// watches it and re-applies the patch GeoJSON + base-layer filter. + int get coveragePatchVersion => _coveragePatchVersion; + + /// The user's own freshly-pinged cells (feature id -> cell), authoritative + /// server state decoded from the fresh z14 tiles. + Map get coveragePatchCells => _coveragePatchCells; + + /// Drop the session patch — the cells belong to one region + grid preset + /// (called on zone change and when the Grid Mode preference changes). + void clearCoveragePatch() { + _coveragePatchCells.clear(); + _coveragePatchVersion++; + } + + /// Post-wardrive live refresh: have the server re-render (`fresh=1`) the + /// tiles around the uploaded ping coords at z11-14, then patch the user's + /// own cells onto the map from the fresh z14 bodies (the base overlay is + /// never swapped — see _coveragePatchCells). z8-10 are skipped: a single + /// ping is sub-pixel there and those whole-region renders are the expensive + /// ones; they ride the server's longer TTL. Zooms above 14 overzoom from + /// the z14 tile. attempt 1 fires +7s after upload; attempt 2 (+10s) runs + /// only when attempt 1 saw no changed tiles (ingestion can lag the post). + Future _freshenAffectedVectorTiles({required int attempt}) async { + final zone = zoneCode; + if (zone == null || + zone != _pendingFreshZone || + _pendingFreshCoords.isEmpty) { + // Zone changed since the coords were queued: they belong to the OLD + // region's grid — freshening them against the new region's server + // would be pure wasted renders. + _pendingFreshCoords.clear(); + return; + } + // Snapshot: a new upload can append (and re-schedule the timer) while the + // fetches below are in flight; only this snapshot is processed and only + // it gets removed afterwards, so late arrivals keep their refresh. + final coords = List>.from(_pendingFreshCoords); + + // A ping's influence is wider than its own cell: blob dilation and cells + // straddling a tile border are emitted in the NEIGHBOURING tile too (the + // server pads its tile queries by ~0.005°). Freshen every tile within + // that margin of the ping, or the spilled part of a cell stays stale in + // the next tile over. + const pad = 0.005; + final tiles = >{}; // 'z/x/y' -> [z, x, y] + var capped = false; + for (final c in coords) { + for (var z = 11; z <= 14 && !capped; z++) { + final n = 1 << z; + int lonToX(double lon) { + final x = (((lon + 180.0) / 360.0) * n).floor(); + return x < 0 ? 0 : (x >= n ? n - 1 : x); + } + + int latToY(double lat) { + final latRad = lat * math.pi / 180.0; + final sinhArg = math.tan(latRad); + final asinh = math.log(sinhArg + math.sqrt(sinhArg * sinhArg + 1)); + final y = ((1.0 - asinh / math.pi) / 2.0 * n).floor(); + return y < 0 ? 0 : (y >= n ? n - 1 : y); + } + + // Containing tile plus any neighbour the pad reaches into (≤4 per z). + for (final xt in {lonToX(c[1] - pad), lonToX(c[1] + pad)}) { + for (final yt in {latToY(c[0] + pad), latToY(c[0] - pad)}) { + if (tiles.length >= 56) { + capped = true; + break; + } + tiles['$z/$xt/$yt'] = [z, xt, yt]; + } + } + } + if (capped) break; + } + if (capped) { + debugLog('[COVERAGE] Fresh-tile fan-out capped at 56 tiles this batch'); + } + + debugLog( + '[COVERAGE] Fresh-tile check (attempt $attempt): ${tiles.length} tiles for ${coords.length} ping(s), z11-14 incl. spill neighbours'); + // Throttled to 4 concurrent renders: a full-burst Future.wait can pile up + // PHP workers and SQLite lock contention on the shared region host (each + // fresh=1 is a live render racing the wardrive INSERT). The whole batch + // still completes in a second or two. + final entries = tiles.entries.toList(); + final z14Bodies = []; + var anyChanged = false; + for (var i = 0; i < entries.length; i += 4) { + final chunk = entries.sublist(i, math.min(i + 4, entries.length)); + await Future.wait(chunk.map((e) async { + final result = await _apiService.freshenVectorTile( + zone: zone, + z: e.value[0], + x: e.value[1], + y: e.value[2], + gsize: _preferences.coverageGridSize); + if (result.changed == true) { + anyChanged = true; + debugLog('[COVERAGE] Retrieved new tile ${e.key}'); + } else if (result.changed == false) { + debugLog('[COVERAGE] No new tile ${e.key} (unchanged)'); + } else { + debugLog('[COVERAGE] Tile ${e.key} fresh check failed'); + } + if (e.value[0] == 14 && result.body != null) { + z14Bodies.add(result.body!); + } + })); + if (_isDisposed) return; + } + + // Patch ONLY the user's own cells onto the map. The base overlay is never + // swapped — the fresh renders above keep the SERVER cache hot (other + // viewers + MapLibre's own tile revalidation pick them up); the cells the + // user is watching update instantly through the patch layer. + final patched = _extractOwnCells(coords, z14Bodies); + if (patched.isNotEmpty) { + for (final cell in patched) { + _coveragePatchCells.remove(cell.id); // re-insert: newest-last ordering + _coveragePatchCells[cell.id] = cell; + } + while (_coveragePatchCells.length > 5000) { + _coveragePatchCells.remove(_coveragePatchCells.keys.first); + } + _coveragePatchVersion++; + // Status histogram of the patched cells (st = server-rendered status category), + // so a debug log carries hard proof of what the server actually rendered for the + // user's own pings (e.g. st={1:3,2:5}) — see the "tiles not green is server-side" note. + final stHist = {}; + for (final cell in patched) { + stHist[cell.st] = (stHist[cell.st] ?? 0) + 1; + } + final stSummary = (stHist.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key))) + .map((e) => '${e.key}:${e.value}') + .join(','); + debugLog( + '[COVERAGE] Patched ${patched.length} cell(s) at your position onto the overlay (attempt $attempt) st={$stSummary}'); + notifyListeners(); + } else { + debugLog( + '[COVERAGE] No cells for your position in the fresh tiles yet (attempt $attempt)'); + } + + if (attempt < 2 && !anyChanged) { + // Re-check at +10s only when the first sweep came back unchanged — + // ingestion can lag a few seconds behind the post. An ACTIVE timer here + // belongs to a newer upload (it re-armed the +7s timer while this run's + // fetches were in flight); let it own the next sweep — overwriting it + // would sweep the new batch seconds too early. + if (!(_vectorFreshTimer?.isActive ?? false)) { + debugLog('[COVERAGE] No changes yet — second fresh-tile check at +10s'); + _vectorFreshTimer = Timer(const Duration(seconds: 3), () { + _freshenAffectedVectorTiles(attempt: 2); + }); + } + } else { + _pendingFreshCoords.removeRange( + 0, math.min(coords.length, _pendingFreshCoords.length)); + } + } + + /// The cells this batch of pings actually touches — the ping's own cell + /// plus its blob reach (Detailed dilates 3×3) — looked up by grid index in + /// the freshly rendered z14 tiles so the patch carries the SERVER-resolved + /// status (priority merge with whatever was already in the cell). + List _extractOwnCells( + List> coords, List bodies) { + if (bodies.isEmpty) return const []; + final steps = kCoverageGridSteps[_preferences.coverageGridSize]; + if (steps == null) return const []; + final reach = _preferences.coverageGridSize == 100 ? 1 : 0; + final wanted = {}; + for (final c in coords) { + final ci = (c[0] / steps[0]).floor(); + final cj = (c[1] / steps[1]).floor(); + for (var di = -reach; di <= reach; di++) { + for (var dj = -reach; dj <= reach; dj++) { + wanted.add('${ci + di}_${cj + dj}'); + } + } + } + final out = []; + final seen = {}; + for (final body in bodies) { + for (final cell in decodeCoverageCells(body)) { + if (wanted.contains('${cell.i}_${cell.j}') && seen.add(cell.id)) { + out.add(cell); + } + } + } + return out; + } + int? get zoneSlotsAvailable => _currentZone?['slots_available'] as int?; int? get zoneSlotsMax => _currentZone?['slots_max'] as int?; String? get nearestZoneName => _nearestZone?['name'] as String?; String? get nearestZoneCode => _nearestZone?['code'] as String?; - double? get nearestZoneDistanceKm => (_nearestZone?['distance_km'] as num?)?.toDouble(); + double? get nearestZoneDistanceKm => + (_nearestZone?['distance_km'] as num?)?.toDouble(); // Zone check retry getters String? get zoneCheckError => _zoneCheckError; @@ -364,13 +1184,136 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Anonymous mode getter bool get isAnonymousRenamed => _isAnonymousRenamed; + // Connection guard getter + bool get isConnecting => _isConnecting; + // Auto-reconnect getters bool get isAutoReconnecting => _isAutoReconnecting; + + /// True when a MyMeshMapper portal token is held. + bool get isPortalLoggedIn => _portalAccountService.isSignedIn; + + /// Cached portal identity, or null when signed out. + PortalAccount? get portalAccount => _portalAccount; + + /// How many radios this account owns (from the last `me` refresh). + int get portalLinkedDeviceCount => _portalLinkedPubkeys.length; + + /// True when at least one radio is suppressed from the link prompt — either + /// the user declined it or it was judged unable to sign. Both are cleared by + /// `resetLinkPromptDeclines()`, so this is what gates the reset control: + /// a sign-unsupported verdict with no visible way back is a dead end. + bool get hasPortalLinkResets => + _portalLinkDeclinedDevices.isNotEmpty || + _portalSignUnsupportedDevices.isNotEmpty; + + /// True when the currently connected radio is already bound to the account. + bool get isCurrentDeviceLinked { + final pubkey = _devicePublicKey?.toUpperCase(); + return pubkey != null && _portalLinkedPubkeys.contains(pubkey); + } + + /// The one-tap link prompt is waiting to be shown. + bool get portalLinkPromptPending => _portalLinkPromptPending; + + /// A sign-in attempt failed and the user has not been told yet. + /// + /// Only failures the app can ATTRIBUTE to an attempt it started reach this. + /// An unsolicited callback — no pending PKCE pair, or a state mismatch — is + /// dropped silently inside `PortalAccountService` on purpose: a deep link is + /// unauthenticated, so any app on the device could otherwise raise this. + String? get portalSignInError => _portalSignInError; + + /// Device name for the link prompt copy (anonymity-aware). + String? get portalLinkPromptDeviceName => displayDeviceName; + int get reconnectAttempt => _reconnectAttempt; + // Zone grace period getters + bool get isInZoneGracePeriod => _isInZoneGracePeriod; + int get zoneGraceSecondsRemaining => _zoneGraceSecondsRemaining; + String get zoneGraceCountdownFormatted => + '${(_zoneGraceSecondsRemaining ~/ 60).toString().padLeft(2, '0')}:' + '${(_zoneGraceSecondsRemaining % 60).toString().padLeft(2, '0')}'; + + // Zone transfer getters + bool get isZoneTransferInProgress => _isZoneTransferInProgress; + String? get zoneTransferFrom => _zoneTransferFrom; + String? get zoneTransferTo => _zoneTransferTo; + + // Focus mode (map ping detail sheet active) + bool _isFocusModeActive = false; + bool get isFocusModeActive => _isFocusModeActive; + set isFocusModeActive(bool value) { + if (_isFocusModeActive != value) { + _isFocusModeActive = value; + notifyListeners(); + } + } + + // A cell-summary / repeater-detail popup is minimized to a bottom pill — hide + // the control panel and zero the map's bottom padding for it, like focus mode. + bool _infoPopupMinimized = false; + bool get infoPopupMinimized => _infoPopupMinimized; + set infoPopupMinimized(bool value) { + if (_infoPopupMinimized != value) { + _infoPopupMinimized = value; + notifyListeners(); + } + } + // Repeater markers getters List get repeaters => List.unmodifiable(_repeaters); + /// Lazy tap-to-inspect: fetch raw coverage points for a clicked map cell from + /// the current zone's app endpoint. Returns `[]` when there is no zone or on + /// failure. The caller aggregates these into a GRID SUMMARY (read-only — no + /// state mutation, so no `notifyListeners`). + Future>> fetchCellCoverage({ + required double lat, + required double lon, + required double radiusMeters, + }) { + final zone = zoneCode; + if (zone == null || zone.isEmpty) { + return Future.value(const >[]); + } + return _apiService.fetchMapData( + zone: zone, + lat: lat, + lon: lon, + radiusMeters: radiusMeters, + ); + } + + /// Lazy tap-to-inspect: fetch the coverage points referencing a repeater + /// (hex-prefix superset) from the current zone's app endpoint. The caller + /// aggregates these into the repeater's BIDIR/TX/RX/DISC/DEAD totals + max + /// range. + /// + /// Returns `null` when the points could not be fetched (no zone yet, or the + /// request failed) and `[]` when the zone genuinely has nothing for this + /// prefix. Keeping those apart is MeshMapper_Server#109: a stale zone list + /// leaves the previous zone's markers tappable after the zone flips, and that + /// must not read as "this repeater heard nothing". + Future>?> fetchRepeaterCoveragePoints({ + required String prefix, + }) { + final zone = zoneCode; + if (zone == null || zone.isEmpty) { + debugWarn('[COVERAGE] repeater points requested with no zone, ' + 'reporting unavailable rather than empty'); + return Future.value(null); + } + return _apiService.fetchRepeaterCoverage(zone: zone, prefix: prefix); + } + + /// Regional boundary polygons loaded from the /border API. + /// Each entry is a `{code: String, polygon: List>}` map where + /// `polygon` holds `[lat, lon]` pairs in the server's original order. + List> get regionBorders => + List.unmodifiable(_regionBorders); + // Regional channels getter (for UI) List get regionalChannels => List.unmodifiable(_regionalChannels); @@ -382,8 +1325,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { List get storedNoiseFloorSessions => List.unmodifiable(_storedNoiseFloorSessions); + // History session map view getters + List? get historySessionMarkers => _historySessionMarkers; + bool get viewingHistorySession => _viewingHistorySession; + // Audio service getters bool get isSoundEnabled => _audioService.isEnabled; + bool get isTxSoundEnabled => _audioService.isTxEnabled; + bool get isRxSoundEnabled => _audioService.isRxEnabled; + bool get isDisconnectAlertEnabled => _preferences.disconnectAlertEnabled; AudioService get audioService => _audioService; bool get isConnected => _connectionStep == ConnectionStep.connected; @@ -397,98 +1347,1851 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get isApiRxOnlyMode => hasApiSession && !txAllowed && rxAllowed; bool get enforceHybrid => _apiService.enforceHybrid; bool get enforceDiscDrop => _apiService.enforceDiscDrop; - bool get discDropEnabled => _preferences.discDropEnabled || _apiService.enforceDiscDrop; + bool get discDropEnabled => + _preferences.discDropEnabled || _apiService.enforceDiscDrop; + + /// Whether the current region forbids flood traffic (region override). + bool get floodDisabled => _apiService.floodDisabled; + + /// Effective flood-traffic visibility: region veto wins over user pref. + bool get floodTrafficEnabled => + !_apiService.floodDisabled && _preferences.floodTrafficEnabled; + int get minModeInterval => _apiService.minModeInterval; bool get enforceHopBytes => _apiService.enforceHopBytes; int get hopBytes => _hopBytes; - int get effectiveHopBytes => enforceHopBytes ? _apiService.apiHopBytes : _hopBytes; + int get effectiveHopBytes => + enforceHopBytes ? _apiService.apiHopBytes : _hopBytes; + int get traceHopBytes => _traceHopBytes; bool get supportsMultiBytePaths => _originalPathHashMode != null; // Offline mode bool get offlineMode => _preferences.offlineMode; List get offlineSessions => _offlineSessionService.sessions; + bool _isUploadingOfflineSession = false; + bool get isUploadingOfflineSession => _isUploadingOfflineSession; // Developer mode bool get developerModeEnabled => _preferences.developerModeEnabled; int get offlinePingCount => _apiQueueService.offlinePingCount; OfflineSessionService get offlineSessionService => _offlineSessionService; - /// Distance in meters from last TX ping position (like wardrive.js) + /// Distance in meters from the last ping of any type (TX, discovery, + /// trace). Display only; the TX skip logic reads its own anchor (#501). double? get distanceFromLastPing { if (_currentPosition == null) return null; - final dist = _gpsService.distanceFromLastPing(_currentPosition!); + final dist = _gpsService.distanceFromLastActivity(_currentPosition!); return dist == double.infinity ? null : dist; } // Countdown timers - CooldownTimer get cooldownTimer => _cooldownTimer; // Shared cooldown for TX Ping and Active Mode - ManualPingCooldownTimer get manualPingCooldownTimer => _manualPingCooldownTimer; // Manual ping cooldown (15 seconds) + CooldownTimer get cooldownTimer => + _cooldownTimer; // Shared cooldown for TX Ping and Active Mode + ManualPingCooldownTimer get manualPingCooldownTimer => + _manualPingCooldownTimer; // Manual ping cooldown (15 seconds) AutoPingTimer get autoPingTimer => _autoPingTimer; RxWindowTimer get rxWindowTimer => _rxWindowTimer; - DiscoveryWindowTimer get discoveryWindowTimer => _discoveryWindowTimer; // Discovery listening window (Passive Mode) + DiscoveryWindowTimer get discoveryWindowTimer => + _discoveryWindowTimer; // Discovery listening window (Passive Mode) + Listenable get timerListenable => _timerListenable; + + void _handleLiveActivityTimerChange() { + if (_liveActivityManualSession && + !_isPingSending && + !_rxWindowTimer.isRunning && + !_manualPingCooldownTimer.isRunning) { + _finishLiveActivitySession(); + return; + } + _scheduleLiveActivitySync(); + } - // ============================================ - // Initialization - // ============================================ + void _startLiveActivitySession({bool manual = false, DateTime? startedAt}) { + if (!_liveActivityService.isSupportedPlatform) return; + if (_liveActivitySessionActive) { + // Starting an automatic mode while a manual-ping activity is still in + // cooldown upgrades the existing activity instead of creating a second. + // The boundary deliberately stays where the manual ping put it: this is + // one session under one ID, and the manual ping and its RX window are + // that session's own results, not a previous session's. + if (!manual && _liveActivityManualSession) { + _liveActivityManualSession = false; + _scheduleLiveActivitySync(immediate: true); + } + return; + } + _liveActivitySessionActive = true; + _liveActivityManualSession = manual; + _liveActivitySessionId = const Uuid().v4(); + // Callers that transmit before reaching here pass the instant they began, + // so the session's own first observation falls inside its boundary. + _liveActivitySessionStartedAt = startedAt ?? DateTime.now(); + _liveActivityCycleStartedAt = _activeLiveActivityCycleStartedAt; + _liveActivityOperation = null; + _scheduleLiveActivitySync(immediate: true); + } - Future _initialize() async { - debugLog('[INIT] AppStateProvider initialization starting...'); + DateTime? get _activeLiveActivityCycleStartedAt { + if (_rxWindowTimer.isRunning && _txLogEntries.isNotEmpty) { + return _txLogEntries.last.timestamp; + } + if (!_discoveryWindowTimer.isRunning) return null; + if (_autoMode == AutoMode.targeted && _traceLogEntries.isNotEmpty) { + return _traceLogEntries.first.timestamp; + } + if (_discLogEntries.isNotEmpty) { + return _discLogEntries.first.timestamp; + } + return null; + } - // Generate or load device ID - _deviceId = const Uuid().v4(); + void _finishLiveActivitySession() { + if (!_liveActivitySessionActive) return; + _liveActivitySessionActive = false; + _liveActivityManualSession = false; + _liveActivityOperation = null; + _liveActivityCycleStartedAt = null; + _scheduleLiveActivitySync(immediate: true); + _liveActivitySessionId = null; + _liveActivitySessionStartedAt = null; + } - // Initialize services - _apiService = ApiService(); - _apiQueueService = ApiQueueService(apiService: _apiService); + void _markLiveActivityOperation(_LiveActivityOperation operation) { + if (!_liveActivitySessionActive || + !_liveActivityService.isSupportedPlatform) { + return; + } + final now = DateTime.now(); + _liveActivityOperation = operation; + _liveActivityCycleStartedAt = now; + _scheduleLiveActivitySync(immediate: true); + } - // Set up session error callback for auto-disconnect - _apiService.onSessionError = (reason, message) async { - debugError('[APP] Session error from API: $reason - $message'); - await handleSessionError(reason, message); - }; + void _scheduleLiveActivitySync({bool immediate = false}) { + if (_isDisposed) return; + // Siri is local App Group state and must not depend on Watch availability. + _appIntentBridge.schedule( + _buildSiriSnapshotMap, + preflightKeyBuilder: _buildSiriSnapshotPreflightKey, + immediate: immediate, + ); + // The watch mirrors state even with no session running — otherwise you + // could never start one from the wrist. + _scheduleWatchSync(immediate: immediate); + _androidAuto.schedule(immediate: immediate); + if (!_liveActivityService.isSupportedPlatform) return; + _liveActivityService.schedule( + _buildLiveActivitySnapshot, + urgencyKeyBuilder: _buildLiveActivityUrgencyKey, + immediate: immediate, + ); + } - // Set up maintenance mode callback (for connected state) - _apiService.onMaintenanceMode = (message, url) { - debugLog('[MAINTENANCE] Callback triggered: $message'); - _handleMaintenanceModeConnected(message, url); - }; + /// The urgent half of the next Live Activity payload, without building one. + /// + /// [_buildLiveActivitySnapshot] resolves the latest ping colour by walking + /// the whole TX, RX, discovery and trace history, and the 500 ms countdown + /// listenable asks for a flush twice a second for the length of a session. + /// This reads scalars only, so the service can decide the flush may wait + /// before paying for any of that. Null means no activity should exist, which + /// keeps ending immediate. + String? _buildLiveActivityUrgencyKey() { + final sessionId = _liveActivitySessionId; + if (_isDisposed || !_liveActivitySessionActive || sessionId == null) { + return null; + } + final phase = _resolveLiveActivityPhase(); + return LiveActivitySnapshot.buildPreflightUrgencyKey( + sessionId: sessionId, + mode: _liveActivityModeTitle, + phase: phase.phase, + phaseTitle: phase.title, + phaseDetail: phase.detail, + phaseEndsAt: phase.endsAt, + phaseDurationMs: _phaseDurationMsFor(phase.endsAt), + isConnected: isConnected, + zoneCode: zoneCode ?? _sessionZoneCode ?? _preferences.iataCode, + showRepeaterNames: _preferences.liveActivityShowNames, + ); + } - _offlineSessionService = OfflineSessionService(); - _deviceModelService = DeviceModelService(); + void _scheduleWatchSync( + {bool immediate = false, bool forceDelivery = false}) { + if (_isDisposed || !_watchBridge.canSync) return; + _watchBridge.schedule( + _buildWatchSnapshot, + urgencyKeyBuilder: _buildWatchUrgencyKey, + immediate: immediate, + forceDelivery: forceDelivery, + ); + } - // Initialize countdown timers with notifyListeners callback for smooth UI updates - _cooldownTimer = CooldownTimer(onUpdate: notifyListeners); - _manualPingCooldownTimer = ManualPingCooldownTimer(onUpdate: notifyListeners); - _autoPingTimer = AutoPingTimer(onUpdate: notifyListeners); - _rxWindowTimer = RxWindowTimer(onUpdate: notifyListeners); - _discoveryWindowTimer = DiscoveryWindowTimer(onUpdate: notifyListeners); + /// The bridge needs to decide whether a flush can wait before it builds the + /// geographic payload. Keep this in the wire model's shared formatter so the + /// cheap preflight and the eventual snapshot cannot drift on what is urgent. + String _buildWatchUrgencyKey() { + final phase = _resolveWatchPhase(); + final controls = _buildWatchControls(); + return WatchSnapshot.buildUrgencyKey( + sessionId: _liveActivitySessionId ?? 'idle', + mode: _resolvedWatchSessionModeTitle, + phase: phase.phase, + phaseTitle: phase.title, + phaseDetail: phase.detail, + phaseEndsAt: phase.endsAt, + isConnected: isConnected, + controls: controls, + cue: _presentableWatchCue, + mapGeoIncluded: _watchBridge.shouldIncludeMapGeo, + ); + } - // Initialize debug logging (enabled by default, respects user preference) - await _initDebugLogs(); + /// Builds the watch payload. + /// + /// Unlike the Live Activity, this is never null while the app is alive: the + /// wrist shows idle and disconnected states too, and the start button has to + /// be reachable before a session exists. + WatchSnapshot? _buildWatchSnapshot() { + if (_isDisposed) return null; + + final phase = _resolveWatchPhase(); + final repeaterState = _buildLiveActivityRepeaters(); + final now = DateTime.now(); + final phaseDurationMs = _phaseDurationMsFor(phase.endsAt); + final pingColor = _resolveWatchPingColor(); + final includeMapGeo = _watchBridge.shouldIncludeMapGeo; + + final core = LiveActivitySnapshot( + sessionId: _liveActivitySessionId ?? 'idle', + // On the watch this field is also the Start button's promise, so it must + // describe the resolver the command will use rather than the ambient + // default that only becomes meaningful after a phone button is pressed. + mode: _resolvedWatchSessionModeTitle, + phase: phase.phase, + phaseTitle: phase.title, + phaseDetail: phase.detail, + phaseEndsAt: phase.endsAt, + phaseDurationMs: phaseDurationMs, + pingColor: pingColor, + isConnected: isConnected, + zoneCode: zoneCode ?? _sessionZoneCode ?? _preferences.iataCode, + txCount: _pingStats.txCount, + rxCount: _pingStats.rxCount, + discoveryCount: _pingStats.discCount, + traceCount: _pingStats.traceCount, + queueSize: _queueSize, + repeaters: repeaterState.repeaters, + totalHeardCount: repeaterState.totalCount, + repeatersAreCurrent: repeaterState.isCurrent, + updatedAt: now, + ); - // Initialize channel service with Public channel only (regional channels added after auth) - await ChannelService.initializePublicChannel(); - debugLog('[APP] Channel service initialized (Public channel only)'); + return WatchSnapshot( + core: core, + geo: _buildWatchGeo(includeMapGeo: includeMapGeo), + controls: _buildWatchControls(), + mapGeoIncluded: includeMapGeo, + availableStartModes: _availableWatchStartModes, + pingColor: pingColor, + cue: _presentableWatchCue, + phaseDurationMs: phaseDurationMs, + updatedAt: now, + ); + } - // Initialize API queue with error/cleanup callbacks - debugLog('[INIT] Initializing API queue service...'); - _apiQueueService.onPersistenceError = (errorMessage) { - logError(errorMessage); - }; - _apiQueueService.onStorageCleanup = (infoMessage) { - logError(infoMessage); // Log cleanup events to error log so user is aware - }; - await _apiQueueService.init(); - debugLog('[INIT] API queue service initialized'); - _apiQueueService.onQueueUpdated = (size) { - _queueSize = size; - notifyListeners(); + /// Total length of the countdown that owns [endsAt]. + /// + /// The phase resolver returns a deadline without saying which timer produced + /// it, so the owner is identified by matching end times. Returns null for + /// deadlines no countdown owns (the zone grace period), in which case the + /// watch shows the remaining time without a progress bar. + int? _phaseDurationMsFor(DateTime? endsAt) { + if (endsAt == null) return null; + for (final timer in [ + _autoPingTimer, + _rxWindowTimer, + _discoveryWindowTimer, + _manualPingCooldownTimer, + _cooldownTimer, + ]) { + if (timer.isRunning && timer.endTime == endsAt) { + return timer.durationMs; + } + } + return null; + } - // Update background service notification with queue size - if (_autoPingEnabled) { - final modeName = _autoMode == AutoMode.passive ? 'Passive Mode' - : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' : 'Active Mode'; - BackgroundServiceManager.updateNotification( + WatchGeo _buildWatchGeo({required bool includeMapGeo}) { + final position = _resolveWatchPosition(); + final ranking = _resolveRankingPosition(); + + // The wrist mirrors the map's "Top Heard" overlay: the latest ping's top + // three by SNR plus the current RX slot. Same source, so the two surfaces + // can never disagree. + final top = _topRepeatersOverlay; + final rxSlot = _rxOverlaySlot; + + // Overlay IDs are hex path hashes. Resolved from the fullest identity each + // row actually arrived with — a discovery response's 64-character public + // key, a trace's 4-byte target — and only falling back to prefix matching + // for the rows that never had anything better, which is TX echoes and + // passive RX. + // + // The prefix fallback indexes per distinct length rather than at one length + // taken from the first row. The RX slot's hash can be a different width + // than the top rows', so a single-length index silently dropped the odd + // row's name and distance while every other row resolved normally. + final repeaterByHex = ExternalSurfaceGeoBuilder.resolveOverlayRepeaters( + repeaters: _repeaters, + displayIds: [ + ...top.map((row) => row.repeaterId), + if (rxSlot != null) rxSlot.repeaterId, + ], + identities: _overlayIdentityById, + ); + final heard = ExternalSurfaceGeoBuilder.buildHeard( + top: top, + rxSlot: rxSlot, + repeaterByHex: repeaterByHex, + topAt: _topRepeatersOverlayUpdatedAt, + rxAt: _liveActivityRxUpdatedAt, + lat: ranking?.lat, + lon: ranking?.lon, + ); + + // The readout still needs the fix and Top Heard, but none of the arrays + // below. Return before merging and sorting four ping histories or sorting + // the repeater catalogue: suppression is meant to save phone work as well + // as radio bytes. + if (!includeMapGeo) { + return WatchGeo( + you: position, + pings: const [], + repeaters: const [], + heard: heard, + linkedRepeaterIds: const [], + ); + } + + // Repeaters heard during the current cycle get the highlight ring. + final heardIds = top.map((r) => r.repeaterId.toUpperCase()).toSet(); + if (rxSlot != null) heardIds.add(rxSlot.repeaterId.toUpperCase()); + + return WatchGeo( + you: position, + pings: ExternalSurfaceGeoBuilder.buildPings( + txPings: _txPings, + rxPings: _rxPings, + discLogEntries: _discLogEntries, + traceLogEntries: _traceLogEntries, + ), + repeaters: ExternalSurfaceGeoBuilder.buildRepeaters( + repeaters: _repeaters, + heardThisCycle: heardIds, + lat: ranking?.lat, + lon: ranking?.lon, + ), + heard: heard, + linkedRepeaterIds: [ + ...ExternalSurfaceGeoBuilder.resolveUniqueHexPrefixes( + repeaters: _repeaters, + prefixes: heardIds, + ).keys, + ], + ); + } + + /// The current fix, reported as it is. + /// + /// **The movement gate used to live here and deliberately does not any + /// more.** It returned the *previous* position until the fix had moved + /// [WatchWire.minMoveMeters], which left the payload fingerprint unchanged so + /// the bridge's dedupe suppressed the send — that is how a parked phone stops + /// streaming GPS jitter at the watch, and it is still how it works. But + /// suppressing a *send* by degrading the *content* also degrades every send + /// that happens for some other reason. A new ping changes the payload + /// regardless, so the packet goes out carrying a puck up to 15 m stale while + /// the ping beside it carries its own transmit-time GPS. The wearer sees the + /// pings leading them in the direction of travel. + /// + /// The gate now sits in [WatchBridgeService], which asks "is this change + /// worth a send?" without touching what gets sent — and in + /// [_resolveRankingPosition], which keeps every *derived* field as still as + /// it was before. Only the puck moved. + WatchPosition? _resolveWatchPosition() { + final position = _currentPosition; + if (position == null) return _lastWatchPosition; + + final resolved = WatchPosition( + lat: position.latitude, + lon: position.longitude, + headingDeg: position.heading.isFinite && position.heading >= 0 + ? position.heading + : null, + accuracyM: position.accuracy.isFinite ? position.accuracy : null, + fixedAt: position.timestamp, + ); + _lastWatchPosition = resolved; + return resolved; + } + + /// Position used to rank repeaters and to measure Top Heard distances, held + /// still until the fix moves [WatchWire.minMoveMeters]. + /// + /// **This is the old gate, kept exactly where it still belongs.** It was + /// removed from the puck because a stale puck is visibly wrong beside a ping + /// carrying its own GPS. Everything derived from position has the opposite + /// requirement: `WatchHeardNode.distanceM` is a full-precision double over a + /// distance measured in kilometres, and the nearest-first repeater order can + /// swap on a metre. Feeding those the live fix would make a parked phone's + /// GPS jitter change the payload every time, which is precisely the send the + /// bridge's gate exists to suppress — and it would slip past that gate, + /// because the gate can only recognise a change confined to the fix itself. + /// + /// Fifteen metres of staleness is invisible in a distance readout and cannot + /// meaningfully reorder repeaters. The puck is the only place it showed. + ({double lat, double lon})? _resolveRankingPosition() { + final position = _currentPosition; + if (position == null) return _rankingPosition; + + final previous = _rankingPosition; + if (previous != null && + !WatchWire.movedEnough( + lastLat: previous.lat, + lastLon: previous.lon, + lat: position.latitude, + lon: position.longitude, + )) { + return previous; + } + + final resolved = (lat: position.latitude, lon: position.longitude); + _rankingPosition = resolved; + return resolved; + } + + ({bool allowed, ExternalCommandReason? reason}) get _manualPingAvailability { + // This must remain the sole copy of the app button's gate. One caller says + // what the wrist may offer while the other decides whether the radio may + // transmit; letting those answers drift makes a stale watch payload unsafe. + // + // That gate has two halves and this used to copy only the inner one. Send + // Ping is built inside `if (!txNotAllowed && floodTrafficVisible)`, so + // with flood traffic off the phone has no such button at all — and since + // the preference defaults off and a regional `flood_disabled` veto forces + // it off, the wrist was admitting the common case, not an edge one. + final floodAllowed = floodTrafficEnabled; + final canPingManual = manualPingValidation == PingValidation.valid; + final isAutoStarting = isAutoPingStarting; + final isTxModeActive = isTxModeRunning; + final isTargetedRunning = isTargetedModeRunning; + final cooldownActive = cooldownTimer.isRunning; + final manualCooldownActive = manualPingCooldownTimer.isRunning; + final txBlockedByOffline = offlineMode && isConnected; + final txNotAllowed = isConnected && !txAllowed; + final rxWindowActive = rxWindowTimer.isRunning; + final pingSending = isPingSending; + final discoveryWindowActive = discoveryWindowTimer.isRunning; + final pendingDisable = isPendingDisable; + final allowed = floodAllowed && + canPingManual && + !isAutoStarting && + !isTxModeActive && + !isTargetedRunning && + !cooldownActive && + !manualCooldownActive && + !txBlockedByOffline && + !txNotAllowed && + !rxWindowActive && + !pingSending && + !discoveryWindowActive && + !pendingDisable; + + // Only describe a refusal that is actually happening. A reason computed + // alongside an allowed ping would surface on the wrist as a status line + // under two working buttons. + final ExternalCommandReason? reason; + if (allowed) { + reason = null; + } else if (!isConnected) { + reason = ExternalCommandReason.notConnected; + } else if (!hasGpsLock) { + reason = const ExternalCommandReason.other('No GPS fix'); + } else if (txBlockedByOffline) { + reason = ExternalCommandReason.offlineMode; + } else if (txNotAllowed) { + reason = ExternalCommandReason.passiveOnly; + } else if (!floodAllowed) { + reason = ExternalCommandReason.floodTrafficOff; + } else if (manualPingValidation == PingValidation.manualCooldownActive || + cooldownActive || + manualCooldownActive || + rxWindowActive || + discoveryWindowActive) { + reason = ExternalCommandReason.coolingDown; + } else if (!canPingManual) { + reason = _externalReasonForPingValidation(manualPingValidation); + } else { + reason = ExternalCommandReason.anotherOperationInProgress; + } + + return (allowed: allowed, reason: reason); + } + + AutoMode get _resolvedWatchSessionMode { + // Phone buttons each carry an explicit mode, but the wrist has one generic + // Start button and `_autoMode` begins as Active before any phone choice has + // established intent. In a passive-only region inheriting that default + // silently selects the one forbidden mode. Once running, preserve the + // actual mode so the same resolver always stops what it started. + if (_autoPingEnabled) return _autoMode; + if (isConnected && !txAllowed) return AutoMode.passive; + return _autoMode; + } + + List get _availableWatchStartModes => + resolveAvailableWatchStartModes( + isConnected: isConnected, + txAllowed: txAllowed, + offlineMode: offlineMode, + floodTrafficEnabled: floodTrafficEnabled, + ); + + String get _resolvedWatchSessionModeTitle => + _resolvedWatchSessionMode.displayName; + + WatchControls _buildWatchControls() { + final cooldownMs = _manualPingCooldownTimer.remainingMs; + final manualPing = _manualPingAvailability; + final passiveStart = sessionStartAvailability(AutoMode.passive); + // The wire has one Start/Stop bit while the preferred start mode lives on + // the watch. Passive is always advertised and is the safe fallback, so this + // bit answers whether at least that start is currently possible; the + // command handler applies the same rule again to the mode actually asked + // for. An active session uses the bit for Stop instead. + final canStartOrStop = + _autoPingEnabled ? isConnected : passiveStart.allowed; + + return WatchControls( + canStartStop: canStartOrStop, + canManualPing: manualPing.allowed, + isSessionActive: _autoPingEnabled, + // Slot ownership must not follow the live manual-ping gate: cooldowns + // and receive windows would otherwise replace Ping with Stop beneath a + // thumb. TX sessions cannot manually ping for their entire lifetime, so + // they keep Stop available even in a region where TX is permitted. + // + // Flood traffic belongs here rather than with the timing guards, for the + // same reason `txAllowed` does: it is a stable configuration fact, not a + // transient one. Leaving it out would hand the map toolbar's one corner + // to a Ping that can never fire — and that corner is where Stop lives. + manualPingApplicable: isConnected && + txAllowed && + floodTrafficEnabled && + !isTxModeRunning && + !isTargetedModeRunning, + manualCooldownEndsAt: + cooldownMs > 0 ? _manualPingCooldownTimer.endTime : null, + // The button already renders its cooldown deadline. The handler still + // returns this refusal to a stale tap, but duplicating it as a status + // line would spend wrist space without adding an explanation. + blockedReason: !_autoPingEnabled && !passiveStart.allowed + ? passiveStart.reason?.compactText + : (manualPing.reason?.code == ExternalCommandReasonCode.coolingDown + ? null + : manualPing.reason?.compactText), + ); + } + + /// The running mode's identity color from the active ping palette, for the + /// Live Activity's progress bar. Hybrid follows the half of the cycle that + /// is on the air, so the bar says which ping type is running right now. + WatchColor? _resolveLiveActivityModeColor(LiveActivityPhase phase) { + switch (phase) { + case LiveActivityPhase.discovering: + case LiveActivityPhase.listeningDiscovery: + case LiveActivityPhase.waitingDiscovery: + return WatchColor.fromColor(PingColors.discSuccess); + case LiveActivityPhase.tracing: + case LiveActivityPhase.listeningTrace: + case LiveActivityPhase.waitingTrace: + return WatchColor.fromColor(PingColors.traceSuccess); + default: + break; + } + return switch (_liveActivityModeTitle.toLowerCase()) { + 'passive' => WatchColor.fromColor(PingColors.discSuccess), + 'trace' => WatchColor.fromColor(PingColors.traceSuccess), + _ => WatchColor.fromColor(PingColors.txSuccess), + }; + } + + /// Colour of the most recent completed coverage event, matching the marker + /// beside it rather than leaving Passive mode stuck on an old TX result. + WatchColor? _resolveWatchPingColor() => + ExternalSurfaceGeoBuilder.latestPingColor( + txPings: _txPings, + rxPings: _rxPings, + discLogEntries: _discLogEntries, + traceLogEntries: _traceLogEntries, + ); + + Map? _buildSiriSnapshotMap() { + if (_isDisposed) return null; + + final recentHeard = SiriSnapshotBuilder.buildRecentHeard( + txEntries: _txLogEntries, + rxEntries: _rxLogEntries, + discoveryEntries: _discLogEntries, + traceEntries: _traceLogEntries, + repeaters: _repeaters, + hopBytes: _hopBytes, + ); + final phase = _resolveWatchPhase(); + final passive = sessionStartAvailability(AutoMode.passive); + final active = sessionStartAvailability(AutoMode.active); + final hybrid = sessionStartAvailability(AutoMode.hybrid); + final manualPing = _manualPingAvailability; + final sessionBusy = _autoPingEnabled || _autoPingStarting; + final availableStartModes = [ + if (!sessionBusy && passive.allowed) AutoMode.passive.name, + if (!sessionBusy && active.allowed) AutoMode.active.name, + if (!sessionBusy && hybrid.allowed) AutoMode.hybrid.name, + ]; + final sessionStartedAt = _liveActivitySessionStartedAt; + final uniqueHeard = SiriSnapshotBuilder.countUniqueRepeatersHeard( + recentHeard.distinctHeard, + sessionStartedAt, + ); + + return SiriSnapshot( + updatedAt: DateTime.now(), + connection: SiriConnectionSnapshot( + isConnected: isConnected, + deviceName: displayDeviceName, + batteryPercent: _currentBatteryPercent, + gpsStatus: _gpsStatus.name, + ), + session: SiriSessionSnapshot( + id: _liveActivitySessionId, + startedAt: sessionStartedAt, + active: _autoPingEnabled, + starting: _autoPingStarting, + mode: sessionBusy ? _autoMode.name : 'idle', + phase: phase.phase.name, + phaseTitle: phase.title, + phaseDetail: phase.detail, + phaseEndsAt: phase.endsAt, + zoneCode: zoneCode ?? _sessionZoneCode ?? _preferences.iataCode, + txCount: _pingStats.txCount, + rxCount: _pingStats.rxCount, + discoveryCount: _pingStats.discCount, + traceCount: _pingStats.traceCount, + queueSize: _queueSize, + uniqueRepeatersHeard: uniqueHeard, + ), + controls: SiriControlsSnapshot( + availableStartModes: availableStartModes, + canStart: !sessionBusy && passive.allowed, + startBlockedReason: _autoPingEnabled + ? 'Already running' + : _autoPingStarting + ? 'Already starting' + : passive.reason?.compactText, + canStop: _autoPingEnabled, + canManualPing: manualPing.allowed, + manualPingBlockedReason: manualPing.reason?.compactText, + manualCooldownEndsAt: _manualPingCooldownTimer.isRunning + ? _manualPingCooldownTimer.endTime + : null, + ), + recentHeard: recentHeard.observations, + repeaters: SiriSnapshotBuilder.buildRepeaterCatalog(_repeaters), + ).toMap(); + } + + /// Cheap semantic projection used to avoid rebuilding the bounded App Group + /// snapshot on every provider notification. Log/catalog revisions cover the + /// expensive collections; the remaining values mirror every scalar emitted + /// by [_buildSiriSnapshotMap]. A five-minute bucket lets time-based repeater + /// staleness advance without restoring the old continuous rebuild loop. + String _buildSiriSnapshotPreflightKey() { + final phase = _resolveWatchPhase(); + final passive = sessionStartAvailability(AutoMode.passive); + final active = sessionStartAvailability(AutoMode.active); + final hybrid = sessionStartAvailability(AutoMode.hybrid); + final manualPing = _manualPingAvailability; + final fiveMinuteBucket = DateTime.now().millisecondsSinceEpoch ~/ + const Duration(minutes: 5).inMilliseconds; + return jsonEncode([ + _siriObservationRevision, + _siriRepeaterCatalogRevision, + _hopBytes, + fiveMinuteBucket, + isConnected, + displayDeviceName, + _currentBatteryPercent, + _gpsStatus.name, + _liveActivitySessionId, + _autoPingEnabled, + _autoPingStarting, + _autoMode.name, + phase.phase.name, + phase.title, + phase.detail, + phase.endsAt?.millisecondsSinceEpoch, + zoneCode ?? _sessionZoneCode ?? _preferences.iataCode, + _pingStats.txCount, + _pingStats.rxCount, + _pingStats.discCount, + _pingStats.traceCount, + _queueSize, + passive.allowed, + passive.reason?.compactText, + active.allowed, + active.reason?.compactText, + hybrid.allowed, + hybrid.reason?.compactText, + manualPing.allowed, + manualPing.reason?.compactText, + _manualPingCooldownTimer.isRunning + ? _manualPingCooldownTimer.endTime?.millisecondsSinceEpoch + : null, + ]); + } + + /// Whether [mode] may begin now, with the reason the wearer should see. + /// + /// This is the sole provider-side start gate. One caller publishes wrist + /// enablement and the other admits the command that mutates session state; + /// separate copies let a stale or racing wrist action enter a state the phone + /// button itself would never offer. + SessionStartAvailability sessionStartAvailability(AutoMode mode) { + final isTransmitMode = mode != AutoMode.passive; + final validation = + isTransmitMode ? autoModeValidation : PingValidation.valid; + final powerConfigured = _preferences.autoPowerSet || + _preferences.powerLevelSet || + _deviceModel != null; + + return resolveSessionStartAvailability( + isTransmitMode: isTransmitMode, + isConnected: isConnected, + antennaConfigured: _preferences.externalAntennaSet, + powerConfigured: powerConfigured, + isPendingDisable: isPendingDisable, + isTargetedRunning: isTargetedModeRunning, + isAutoStarting: isAutoPingStarting, + cooldownActive: _cooldownTimer.isRunning, + isPingSending: isPingSending, + rxWindowActive: _rxWindowTimer.isRunning, + txBlockedByOffline: offlineMode && isConnected, + txNotAllowed: isConnected && !txAllowed, + floodTrafficEnabled: floodTrafficEnabled, + transmitValidationReason: validation == PingValidation.valid + ? null + : _externalReasonForPingValidation(validation), + ); + } + + /// Applies the app's current session/radio rules to any external transport. + ExternalCommandAdmission admitExternalSessionCommand( + ExternalSessionCommand command, + ) { + if (_isDisposed) { + return const ExternalCommandAdmission( + disposition: ExternalCommandDisposition.refused, + reason: ExternalCommandReason.appClosing, + ); + } + + final transition = resolveExternalSessionTransition( + command: command, + isSessionActive: _autoPingEnabled, + isSessionStarting: _autoPingStarting, + currentMode: _autoMode.name, + currentSessionId: _liveActivitySessionId ?? 'idle', + currentModeLabel: _autoMode.displayName, + ); + if (!transition.shouldExecute) return transition; + + switch (command.kind) { + case ExternalSessionCommandKind.startSession: + final mode = transition.mode; + if (mode == null) { + return const ExternalCommandAdmission( + disposition: ExternalCommandDisposition.refused, + reason: ExternalCommandReason.other( + 'Unsupported start mode', + ), + ); + } + final availability = + sessionStartAvailability(_autoModeFromExternal(mode)); + if (!availability.allowed) { + return ExternalCommandAdmission( + disposition: ExternalCommandDisposition.refused, + reason: availability.reason ?? ExternalCommandReason.couldNotStart, + mode: mode, + ); + } + return transition; + + case ExternalSessionCommandKind.stopSession: + return transition; + + case ExternalSessionCommandKind.manualPing: + final availability = _manualPingAvailability; + if (!availability.allowed) { + return ExternalCommandAdmission( + disposition: ExternalCommandDisposition.refused, + reason: availability.reason ?? + const ExternalCommandReason.other('Ping unavailable'), + ); + } + return transition; + } + } + + /// Waits for the admitted operation's real outcome. Watch intentionally + /// does not await this; Siri does, so it never says "started" at admission. + Future executeExternalSessionCommand( + ExternalSessionCommand command, + ExternalCommandAdmission admission, + ) async { + if (!admission.shouldExecute) { + return ExternalCommandCompletion( + success: admission.disposition == ExternalCommandDisposition.noOp, + disposition: admission.disposition, + message: admission.reason, + sessionId: _liveActivitySessionId, + mode: admission.mode, + ); + } + + _lastSessionCheckFailureReason = null; + // Re-read the clock at the checkpoint rather than capturing a decision + // made at admission, which is what makes this cover the awaited work in + // between. + bool cannotStillCommit() => externalCommandCannotCommit(command.expiresAt); + try { + switch (command.kind) { + case ExternalSessionCommandKind.startSession: + final mode = admission.mode!; + final started = await toggleAutoPing( + _autoModeFromExternal(mode), + shouldAbortBeforeTransmit: cannotStillCommit, + ); + if (!started || !_autoPingEnabled) { + return ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: _externalStartFailureReason(mode), + mode: mode, + ); + } + return ExternalCommandCompletion( + success: true, + disposition: ExternalCommandDisposition.admitted, + message: ExternalCommandReason.other( + 'MeshMapper started in ${mode.displayName} mode.', + ), + sessionId: _liveActivitySessionId, + mode: mode, + ); + + case ExternalSessionCommandKind.stopSession: + final stopped = await toggleAutoPing(_autoMode); + if (!stopped) { + return const ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: ExternalCommandReason.couldNotStop, + ); + } + final stopping = _autoPingEnabled || isPendingDisable; + return ExternalCommandCompletion( + success: true, + disposition: ExternalCommandDisposition.admitted, + message: ExternalCommandReason.other( + stopping ? 'MeshMapper is stopping.' : 'MeshMapper stopped.', + ), + sessionId: stopping ? _liveActivitySessionId : null, + ); + + case ExternalSessionCommandKind.manualPing: + final sent = await sendPing( + shouldAbortBeforeTransmit: cannotStillCommit, + ); + if (!sent) { + return ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: _lastSessionCheckFailureReason ?? + ExternalCommandReason.pingFailed, + ); + } + return const ExternalCommandCompletion( + success: true, + disposition: ExternalCommandDisposition.admitted, + message: ExternalCommandReason.other( + 'MeshMapper sent a manual ping.', + ), + ); + } + } catch (error) { + debugError('[EXTERNAL] ${command.kind.name} failed: $error'); + return ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: command.kind == ExternalSessionCommandKind.startSession + ? _externalStartFailureReason(admission.mode) + : command.kind == ExternalSessionCommandKind.stopSession + ? ExternalCommandReason.couldNotStop + : _lastSessionCheckFailureReason ?? + ExternalCommandReason.pingFailed, + mode: admission.mode, + ); + } + } + + Future _handleSiriCommand( + AppIntentCommand command, + ) async { + if (command.kind == AppIntentCommandKind.connectLastCompanion) { + return _connectToLastCompanion(command); + } + final sessionCommand = command.toExternalSessionCommand(); + if (sessionCommand == null) { + return const ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: ExternalCommandReason.other('Unsupported command'), + ); + } + final admission = admitExternalSessionCommand(sessionCommand); + final completion = + await executeExternalSessionCommand(sessionCommand, admission); + return externalCommandCompletionForVoice( + command: sessionCommand, + completion: completion, + ); + } + + Future _connectToLastCompanion( + AppIntentCommand command, + ) async { + try { + await _rememberedDeviceReady.future.timeout(const Duration(seconds: 10)); + } on TimeoutException { + return const ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: ExternalCommandReason.other( + 'MeshMapper is still starting. Open the app and try again.', + ), + ); + } + // The deadline the coordinator stamped can expire during that wait: a cold + // launch can spend the whole readiness budget and leave the caller already + // gone. Check it before doing any further work; admission runs after this + // and checks it again, so an expiry landing in between is still caught. + if (externalCommandDeadlineRefusal(command.expiresAt) != null) { + return const ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: ExternalCommandReason.other( + externalCommandExpiredVoiceMessage, + ), + ); + } + if (_isDisposed) { + return const ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: ExternalCommandReason.appClosing, + ); + } + + final remembered = _rememberedDevice; + final connectedDevice = + (_activeTransport ?? _bluetoothService).connectedDevice; + final isConnectedToRemembered = remembered != null && + connectedDevice?.id == remembered.id && + _selectedTransport == remembered.transportType; + final admission = resolveLastCompanionConnection( + command: command, + hasRememberedCompanion: remembered != null, + isConnected: isConnected, + isConnectedToRememberedCompanion: isConnectedToRemembered, + isConnecting: _isConnecting || _isAutoReconnecting, + canReconnectWithoutUserInput: + remembered?.transportType != TransportType.usbSerial, + ); + + if (!admission.shouldExecute) { + final message = switch (admission.reason?.code) { + ExternalCommandReasonCode.noRememberedCompanion => + 'MeshMapper has no remembered companion. Connect one in the app first.', + ExternalCommandReasonCode.alreadyConnected => + 'MeshMapper is already connected to ${remembered!.displayName}.', + ExternalCommandReasonCode.anotherCompanionConnected => + 'MeshMapper is already connected to ${displayDeviceName ?? connectedDevice?.name ?? 'another companion'}.', + ExternalCommandReasonCode.alreadyConnecting => + 'MeshMapper is already connecting. Try again shortly.', + ExternalCommandReasonCode.userInteractionRequired => + 'The last companion uses USB and must be selected in MeshMapper.', + ExternalCommandReasonCode.commandExpired => + externalCommandExpiredVoiceMessage, + _ => admission.reason?.compactText ?? 'MeshMapper could not connect.', + }; + return ExternalCommandCompletion( + success: admission.disposition == ExternalCommandDisposition.noOp, + disposition: admission.disposition, + message: ExternalCommandReason.other(message), + ); + } + + // Connecting cannot be cancelled once the transport is dialling, so the + // deadline is enforced by not starting a reconnect that has no room left to + // finish. Refusing here is free; refusing halfway through is not possible. + if (externalCommandCannotCommit(command.expiresAt)) { + return const ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: ExternalCommandReason.other( + externalCommandExpiredVoiceMessage, + ), + ); + } + + try { + await reconnectToRememberedDevice(); + final reconnectedDevice = + (_activeTransport ?? _bluetoothService).connectedDevice; + if (isConnected && reconnectedDevice?.id == remembered!.id) { + return ExternalCommandCompletion( + success: true, + disposition: ExternalCommandDisposition.admitted, + message: ExternalCommandReason.other( + 'MeshMapper connected to ${remembered.displayName}.', + ), + ); + } + return ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: ExternalCommandReason.other( + _connectionError ?? + 'MeshMapper could not connect to ${remembered!.displayName}.', + ), + ); + } catch (error) { + debugError('[SIRI] Connect to last companion failed: $error'); + return ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: ExternalCommandReason.other( + _connectionError ?? + 'MeshMapper could not connect to ${remembered!.displayName}.', + ), + ); + } + } + + static AutoMode _autoModeFromExternal(ExternalSessionMode mode) => + switch (mode) { + ExternalSessionMode.active => AutoMode.active, + ExternalSessionMode.passive => AutoMode.passive, + ExternalSessionMode.hybrid => AutoMode.hybrid, + }; + + ExternalCommandReason _externalStartFailureReason( + ExternalSessionMode? mode, + ) { + final sessionReason = _lastSessionCheckFailureReason; + if (sessionReason != null) return sessionReason; + if (mode != ExternalSessionMode.passive && _cooldownTimer.isRunning) { + return ExternalCommandReason.coolingDown; + } + return ExternalCommandReason.couldNotStart; + } + + ExternalCommandReason _externalReasonForPingValidation( + PingValidation validation, + ) => + switch (validation) { + PingValidation.notConnected => + ExternalCommandReason.notConnectedToDevice, + PingValidation.externalAntennaRequired => + ExternalCommandReason.selectAntennaOptionBeforePinging, + PingValidation.powerLevelRequired => + ExternalCommandReason.selectPowerLevelUnknownDevice, + PingValidation.noGpsLock => ExternalCommandReason.waitingForGpsLock, + PingValidation.gpsDataStale => ExternalCommandReason.gpsDataStale, + PingValidation.gpsInaccurate => ExternalCommandReason.gpsAccuracyLow, + PingValidation.cooldownActive => ExternalCommandReason.waitFiveSeconds, + PingValidation.manualCooldownActive => + ExternalCommandReason.waitFifteenSeconds, + PingValidation.txNotAllowed => ExternalCommandReason.zoneAtCapacity, + PingValidation.valid || + PingValidation.outsideGeofence || + PingValidation.tooCloseToLastPing => + ExternalCommandReason.other(validation.message), + }; + + /// Decides whether an intent from the wrist may begin. + /// + /// Returns null when accepted, or a reason to show on the watch. Every guard + /// is re-evaluated here: the watch's view of what's permitted may be stale, + /// and a stale payload must never be able to cause a transmit. + /// + /// Once admitted, the action deliberately outlives this synchronous reply: + /// WatchConnectivity cannot wait for BLE or server work. Successful outcomes + /// already surface through session, phase, and ping-colour snapshots; a late + /// failure gets its own cue so dropping completion from the ack loses nothing. + String? _handleWatchCommand(WatchCommand command) { + switch (command.kind) { + case WatchCommandKind.requestSnapshot: + // This command carries two intents. A genuine plea for state — after a + // relaunch or a resume onto a retained context of unknown age — must + // not be answered with dedupe's silence. A change of map demand is not + // that, and the lease behind it renews every five minutes for as long + // as the map stays hidden, so forcing those would spend the radio + // exactly where the lease exists to save it. + _scheduleWatchSync( + immediate: true, + forceDelivery: command.forceRefresh, + ); + return null; + + case WatchCommandKind.startSession: + final requested = resolveWatchRequestedStartMode( + requestedMode: command.mode, + isConnected: isConnected, + txAllowed: txAllowed, + ); + if (requested.refusal != null) return requested.refusal; + final mode = switch (requested.mode) { + WatchStartMode.passive => AutoMode.passive.name, + WatchStartMode.hybrid => AutoMode.hybrid.name, + null => resolveLegacyWatchStartMode( + currentMode: _resolvedWatchSessionMode.name, + isConnected: isConnected, + txAllowed: txAllowed, + ), + }; + return _admitAndRunWatchCommand(command, mode: mode); + + case WatchCommandKind.stopSession: + return _admitAndRunWatchCommand(command); + + case WatchCommandKind.manualPing: + return _admitAndRunWatchCommand(command); + } + } + + String? _admitAndRunWatchCommand(WatchCommand command, {String? mode}) { + final kind = switch (command.kind) { + WatchCommandKind.startSession => ExternalSessionCommandKind.startSession, + WatchCommandKind.stopSession => ExternalSessionCommandKind.stopSession, + WatchCommandKind.manualPing => ExternalSessionCommandKind.manualPing, + WatchCommandKind.requestSnapshot => throw ArgumentError.value( + command.kind, + 'kind', + 'Snapshot is a Watch transport command', + ), + }; + final external = ExternalSessionCommand( + id: command.id ?? const Uuid().v4(), + source: ExternalCommandSource.watch, + kind: kind, + issuedAt: command.issuedAt ?? DateTime.now(), + mode: mode, + sessionId: command.sessionId, + ); + final admission = admitExternalSessionCommand(external); + if (admission.disposition == ExternalCommandDisposition.refused) { + return admission.reason?.compactText; + } + if (admission.shouldExecute) { + unawaited(executeExternalSessionCommand(external, admission).then( + (completion) { + if (!completion.success) { + _emitWatchFailure( + completion.message?.compactText ?? 'Command failed', + ); + } + }, + )); + } + return null; + } + + /// A tap on the head unit, admitted exactly as a wrist tap or a Siri phrase + /// is. + /// + /// The only car-specific work is resolving the start mode. The strip shows + /// one button and no mode picker — choosing a mode is setup, which the + /// distraction rules forbid while driving — so the command arrives with none + /// and inherits whatever the status row is already promising. That has to + /// happen here rather than inside [resolveExternalSessionTransition]: the + /// shared resolver refuses a modeless start on purpose, and teaching it a + /// third surface's default would put a phone preference inside the rules + /// every surface shares. + ExternalCommandReason? _handleAutoCommand(ExternalSessionCommand command) { + var admitted = command; + if (command.kind == ExternalSessionCommandKind.startSession && + command.mode == null) { + final requested = resolveWatchRequestedStartMode( + requestedMode: null, + isConnected: isConnected, + txAllowed: txAllowed, + ); + final refusal = requested.refusal; + if (refusal != null) return ExternalCommandReason.other(refusal); + admitted = ExternalSessionCommand( + id: command.id, + source: command.source, + kind: command.kind, + issuedAt: command.issuedAt, + expiresAt: command.expiresAt, + mode: resolveLegacyWatchStartMode( + currentMode: _resolvedWatchSessionMode.name, + isConnected: isConnected, + txAllowed: txAllowed, + ), + sessionId: command.sessionId, + ); + } + + final admission = admitExternalSessionCommand(admitted); + if (admission.disposition == ExternalCommandDisposition.refused) { + return admission.reason; + } + if (admission.shouldExecute) { + unawaited(executeExternalSessionCommand(admitted, admission).then( + (completion) { + if (!completion.success) { + _emitWatchFailure( + completion.message?.compactText ?? 'Command failed', + ); + } + }, + )); + } + return null; + } + + void _emitWatchFailure(String message) { + if (_isDisposed) return; + _watchCue = WatchHapticCue( + id: const Uuid().v4(), + kind: 'failure', + issuedAt: DateTime.now(), + message: message, + ); + _scheduleWatchSync(immediate: true); + _androidAuto.schedule(immediate: true); + } + + void _handleWatchDiagnosticsChanged() { + if (_watchBridge.diagnostics.value.paired) { + unawaited(_rememberWatchPairing()); + } + } + + Future _rememberWatchPairing() async { + if (_hasEverPairedWatch) return; + + // Pairing history is intentionally one-way. An unpaired watch is the most + // important time to keep this diagnostic reachable, so no later native + // false is allowed to erase the evidence that the feature once existed. + _hasEverPairedWatch = true; + _notifyWatchDiagnosticVisibilityChanged(); + + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) return; + try { + await box.put('watch_has_ever_been_paired', true); + } catch (error) { + debugError('[WATCH] Failed to persist pairing history: $error'); + } + } + + void _notifyWatchDiagnosticVisibilityChanged() { + if (_isDisposed) return; + // The provider's normal notifier also schedules a watch snapshot. This + // flag only controls Settings visibility, so bypass that transport side + // effect and keep pairing persistence strictly observational. + super.notifyListeners(); + } + + ({ + LiveActivityPhase phase, + String title, + String? detail, + DateTime? endsAt, + }) _resolveWatchPhase() { + final shared = _resolveLiveActivityPhase(); + final watchPhase = resolveWatchSurfacePhase( + sharedPhase: shared.phase, + isSessionActive: _autoPingEnabled, + isSessionStarting: _autoPingStarting, + ); + if (watchPhase == shared.phase) return shared; + + return ( + phase: LiveActivityPhase.idle, + title: 'Ready', + detail: 'No session running', + endsAt: null, + ); + } + + LiveActivitySnapshot? _buildLiveActivitySnapshot() { + final sessionId = _liveActivitySessionId; + if (!_liveActivitySessionActive || sessionId == null) { + return null; + } + + final phase = _resolveLiveActivityPhase(); + final repeaterState = _buildLiveActivityRepeaters(); + final phaseDurationMs = _phaseDurationMsFor(phase.endsAt); + final pingColor = _resolveWatchPingColor(); + + return LiveActivitySnapshot( + sessionId: sessionId, + mode: _liveActivityModeTitle, + phase: phase.phase, + phaseTitle: phase.title, + phaseDetail: phase.detail, + phaseEndsAt: phase.endsAt, + phaseDurationMs: phaseDurationMs, + pingColor: pingColor, + modeColor: _resolveLiveActivityModeColor(phase.phase), + isConnected: isConnected, + zoneCode: zoneCode ?? _sessionZoneCode ?? _preferences.iataCode, + noiseFloorDbm: _bucketedNoiseFloorDbm(), + companionBatteryPct: _bucketedCompanionBatteryPct(), + showRepeaterNames: _preferences.liveActivityShowNames, + txCount: _pingStats.txCount, + rxCount: _pingStats.rxCount, + discoveryCount: _pingStats.discCount, + traceCount: _pingStats.traceCount, + queueSize: _queueSize, + repeaters: repeaterState.repeaters, + totalHeardCount: repeaterState.totalCount, + repeatersAreCurrent: repeaterState.isCurrent, + updatedAt: DateTime.now(), + ); + } + + /// 2 dBm steps so ambient jitter on the 5 second poll never changes the + /// Live Activity payload fingerprint; the exact value stays a phone fact. + int? _bucketedNoiseFloorDbm() { + final noiseFloor = _currentNoiseFloor; + if (noiseFloor == null) return null; + return (noiseFloor / 2).round() * 2; + } + + /// 5 percent steps so routine drain never changes the Live Activity payload + /// fingerprint; the exact value stays a phone-screen fact. + int? _bucketedCompanionBatteryPct() { + final percent = _currentBatteryPercent; + if (percent == null) return null; + return ((percent / 5).round() * 5).clamp(0, 100); + } + + ({ + LiveActivityPhase phase, + String title, + String? detail, + DateTime? endsAt, + }) _resolveLiveActivityPhase() { + if (_isInZoneGracePeriod) { + return ( + phase: LiveActivityPhase.pausedOutsideZone, + title: 'Outside service area', + detail: 'Searching for a nearby wardriving zone', + endsAt: _zoneGraceEndsAt, + ); + } + + if (_isZoneTransferInProgress) { + return ( + phase: LiveActivityPhase.pausedOutsideZone, + title: 'Changing region…', + detail: [_zoneTransferFrom, _zoneTransferTo] + .whereType() + .join(' → '), + endsAt: null, + ); + } + + if (_isAutoReconnecting || _connectionStep == ConnectionStep.reconnecting) { + return ( + phase: LiveActivityPhase.disconnected, + title: 'Reconnecting…', + detail: 'Restoring MeshCore connection', + endsAt: null, + ); + } + + if (!isConnected) { + return ( + phase: LiveActivityPhase.disconnected, + title: _connectionStep == ConnectionStep.disconnecting + ? 'Disconnecting…' + : 'Device disconnected', + detail: 'Open MeshMapper to reconnect', + endsAt: null, + ); + } + + if (isPendingDisable) { + return ( + phase: LiveActivityPhase.stopping, + title: 'Stopping…', + detail: 'Finishing the current listening window', + endsAt: _rxWindowTimer.endTime ?? _discoveryWindowTimer.endTime, + ); + } + + if (_gpsStatus != GpsStatus.locked) { + return ( + phase: LiveActivityPhase.waitingForGps, + title: 'Waiting for GPS', + detail: _liveActivityGpsLabel, + endsAt: null, + ); + } + + if ((_autoMode == AutoMode.active || + _autoMode == AutoMode.hybrid || + _autoMode == AutoMode.targeted) && + !txAllowed) { + return ( + phase: LiveActivityPhase.txBlocked, + title: 'TX unavailable', + detail: 'This zone is currently passive-only', + endsAt: null, + ); + } + + if (_liveActivityManualSession && _isPingSending) { + return ( + phase: LiveActivityPhase.sending, + title: 'Sending ping…', + detail: null, + endsAt: null, + ); + } + + if (_discoveryWindowTimer.isRunning) { + final isTrace = _autoMode == AutoMode.targeted; + return ( + phase: isTrace + ? LiveActivityPhase.listeningTrace + : LiveActivityPhase.listeningDiscovery, + title: isTrace ? 'Listening for trace…' : 'Listening…', + detail: isTrace ? _targetRepeaterDisplayName : 'Discovery responses', + endsAt: _discoveryWindowTimer.endTime, + ); + } + + if (_rxWindowTimer.isRunning) { + return ( + phase: LiveActivityPhase.listening, + title: 'Listening…', + detail: 'Waiting for repeater echoes', + endsAt: _rxWindowTimer.endTime, + ); + } + + if (_liveActivityManualSession && _manualPingCooldownTimer.isRunning) { + return ( + phase: LiveActivityPhase.cooldown, + title: 'Cooldown', + detail: 'Manual ping available when the timer ends', + endsAt: _manualPingCooldownTimer.endTime, + ); + } + + if (_autoPingTimer.isRunning) { + if (_autoPingTimer.skipReason != null) { + return ( + phase: LiveActivityPhase.skipped, + title: 'Ping skipped', + detail: 'Move at least ${PingService.currentMinDistance} m', + endsAt: _autoPingTimer.endTime, + ); + } + + if (_autoMode == AutoMode.passive) { + return ( + phase: LiveActivityPhase.waitingDiscovery, + title: 'Next discovery', + detail: null, + endsAt: _autoPingTimer.endTime, + ); + } + + if (_autoMode == AutoMode.targeted) { + return ( + phase: LiveActivityPhase.waitingTrace, + title: 'Next trace', + detail: _targetRepeaterDisplayName, + endsAt: _autoPingTimer.endTime, + ); + } + + return ( + phase: LiveActivityPhase.waiting, + title: 'Next ping', + detail: null, + endsAt: _autoPingTimer.endTime, + ); + } + + switch (_liveActivityOperation) { + case _LiveActivityOperation.sending: + return ( + phase: LiveActivityPhase.sending, + title: 'Sending ping…', + detail: null, + endsAt: null, + ); + case _LiveActivityOperation.discovering: + return ( + phase: LiveActivityPhase.discovering, + title: 'Discovering…', + detail: 'Requesting nearby repeaters', + endsAt: null, + ); + case _LiveActivityOperation.tracing: + return ( + phase: LiveActivityPhase.tracing, + title: 'Tracing repeater…', + detail: _targetRepeaterDisplayName, + endsAt: null, + ); + case null: + break; + } + + if (_autoPingStarting || !_autoPingEnabled) { + return ( + phase: LiveActivityPhase.starting, + title: 'Preparing session…', + detail: null, + endsAt: null, + ); + } + + return ( + phase: LiveActivityPhase.active, + title: '$_liveActivityModeTitle active', + detail: 'Waiting for the next cycle', + endsAt: null, + ); + } + + ({ + List repeaters, + int totalCount, + bool isCurrent, + }) _buildLiveActivityRepeaters() { + final cycleStartedAt = _liveActivityCycleStartedAt; + final topIsCurrent = cycleStartedAt != null && + _liveActivityRepeatersUpdatedAt != null && + !_liveActivityRepeatersUpdatedAt!.isBefore(cycleStartedAt); + final rxIsCurrent = cycleStartedAt != null && + _liveActivityRxUpdatedAt != null && + !_liveActivityRxUpdatedAt!.isBefore(cycleStartedAt); + final hasCurrent = topIsCurrent || rxIsCurrent; + + final includeTop = !hasCurrent || topIsCurrent; + final includeRx = !hasCurrent || rxIsCurrent; + final repeatersById = {}; + + if (includeTop) { + for (final repeater in _liveActivityRepeaters) { + if (!repeater.snr.isFinite) continue; + final id = repeater.repeaterId.toUpperCase(); + repeatersById[id] = LiveActivityRepeater( + id: id, + name: _resolveRepeaterDisplayName(id), + snr: repeater.snr, + typeColor: ExternalSurfaceGeoBuilder.overlayTypeColor(repeater.type), + snrColor: ExternalSurfaceGeoBuilder.snrColor(repeater.snr), + ); + } + } + + final rx = _rxOverlaySlot; + if (includeRx && rx != null && rx.snr.isFinite) { + final id = rx.repeaterId.toUpperCase(); + final existing = repeatersById[id]; + if (existing == null || rx.snr > existing.snr) { + repeatersById[id] = LiveActivityRepeater( + id: id, + name: _resolveRepeaterDisplayName(id), + snr: rx.snr, + typeColor: + ExternalSurfaceGeoBuilder.overlayTypeColor(OverlayPingType.rx), + snrColor: ExternalSurfaceGeoBuilder.snrColor(rx.snr), + ); + } + } + + final repeaters = repeatersById.values.toList() + ..sort((a, b) => b.snr.compareTo(a.snr)); + + var totalCount = includeTop ? _liveActivityRepeaterTotalCount : 0; + if (includeRx && + rx != null && + rx.snr.isFinite && + !_liveActivityRepeaters.any( + (entry) => + entry.repeaterId.toUpperCase() == rx.repeaterId.toUpperCase(), + )) { + totalCount++; + } + if (totalCount < repeaters.length) totalCount = repeaters.length; + + return ( + // Four so the watch Smart Stack card's two-by-two grid fills, which also + // lets the RX slot survive alongside the three top-SNR rows the way the + // map overlay shows them. Surfaces that want fewer trim their own. + repeaters: repeaters.take(4).toList(growable: false), + totalCount: totalCount, + isCurrent: hasCurrent, + ); + } + + String get _liveActivityModeTitle { + if (_liveActivityManualSession) return 'Manual'; + return _autoMode.displayName; + } + + String get _liveActivityGpsLabel => switch (_gpsStatus) { + GpsStatus.permissionDenied => 'Location permission required', + GpsStatus.disabled => 'Location services disabled', + GpsStatus.searching => 'Searching for GPS signal', + GpsStatus.locked => 'GPS locked', + GpsStatus.outsideGeofence => 'Outside service area', + }; + + String? get _targetRepeaterDisplayName { + final id = _targetRepeaterId; + if (id == null || id.isEmpty) return null; + return _resolveRepeaterDisplayName(id) ?? id.toUpperCase(); + } + + String? _resolveRepeaterDisplayName(String rawId) { + final displayId = rawId.toUpperCase(); + + // Try the fullest identity the row arrived with, then the display hash. + // + // **The fallback is the point.** A discovery response's public key is a + // better identity, but the catalogue is matched by an 8-character `hexId` + // that the API frequently omits, and the display hash additionally resolves + // through `displayHexId` — which falls back to the short numeric ID when + // there is no hex at all. Trying only the longer form threw away the lookup + // that was doing the work, and left named repeaters unnamed. + final identity = _overlayIdentityById[displayId]; + final match = (identity == null + ? null + : ExternalSurfaceGeoBuilder.resolveRepeater( + repeaters: _repeaters, + id: identity, + hopBytes: _hopBytes, + )) ?? + ExternalSurfaceGeoBuilder.resolveRepeater( + repeaters: _repeaters, + id: displayId, + hopBytes: _hopBytes, + ); + + final name = match?.name; + return name == null || name == 'Unknown' ? null : name; + } + + // ============================================ + // Initialization + // ============================================ + + Future _initialize() async { + debugLog('[INIT] AppStateProvider initialization starting...'); + + // Generate or load device ID + _deviceId = const Uuid().v4(); + + // Initialize services + _apiService = ApiService(); + _apiQueueService = ApiQueueService(apiService: _apiService); + + // Initialize custom API forwarding service + _customApiService = CustomApiService(prefsGetter: () => _preferences); + _customApiService.onError = (message) { + logError('Custom API: $message', + severity: ErrorSeverity.warning, autoSwitch: false); + }; + _customApiService.contactGetter = () { + final pk = _devicePublicKey; + return (pk != null && pk.length >= 8) + ? pk.substring(0, 8).toUpperCase() + : null; + }; + _customApiService.iataGetter = () => zoneCode ?? _preferences.iataCode; + _apiQueueService.customApiService = _customApiService; + + // MyMeshMapper portal account. Mobile only — the sign-in flow needs an + // OS-registered URL scheme, which the web build cannot have. + _portalAccountService = PortalAccountService( + appLinks: kIsWeb ? null : AppLinks(), + ); + _portalAccountService.deviceLabelProvider = _portalDeviceLabel; + _portalAccountService.onAccountChanged = () { + _portalAccount = _portalAccountService.account; + _portalLinkedPubkeys = _portalAccountService.linkedPubkeys + .map((entry) => entry.pubkey.toUpperCase()) + .toList(); + unawaited(_savePortalAccountState()); + // Account state is NOT map state — plain notify only (Critical Rule 9). + notifyListeners(); + }; + _portalAccountService.onSignedOut = (reason) { + debugLog('[ACCOUNT] Signed out ($reason) — clearing the cached identity'); + _portalAccount = null; + _portalLinkedPubkeys = []; + // Declines and sign-unsupported are DEVICE preferences, not account + // data: they deliberately survive a sign-out. + unawaited(_savePortalAccountState()); + notifyListeners(); + }; + _portalAccountService.onSignInComplete = (success, errorCode) { + debugLog('[ACCOUNT] Sign-in complete: success=$success, ' + 'error=${errorCode ?? 'none'}'); + // A success clears any stale code, so a late toast can never contradict + // a screen that already says "signed in". + _portalSignInError = success ? null : (errorCode ?? 'unknown'); + notifyListeners(); + }; + + // Set up session error callback for auto-disconnect + _apiService.onSessionError = (reason, message, {subReason}) async { + debugError('[APP] Session error from API: $reason' + '${subReason != null ? ' ($subReason)' : ''} - $message'); + await handleSessionError(reason, message, subReason: subReason); + }; + + // A new session id makes every wire tag already queued under the old one + // undeliverable (auto-reconnect preserves the queue across re-auth). Drop + // those pings honestly instead of uploading claims the server will skip + // while reporting success. + _apiService.onSessionIdChanged = () { + unawaited(_apiQueueService.dropStaleTaggedItems()); + }; + + // Set up maintenance mode callback (for connected state) + _apiService.onMaintenanceMode = (message, url) { + debugLog('[MAINTENANCE] Callback triggered: $message'); + _handleMaintenanceModeConnected(message, url); + }; + + _offlineSessionService = OfflineSessionService(); + _deviceModelService = DeviceModelService(); + + // Initialize countdown timers. They self-notify via ChangeNotifier so only + // widgets listening to the timers directly rebuild on each 500ms tick. + _cooldownTimer = CooldownTimer(); + _manualPingCooldownTimer = ManualPingCooldownTimer(); + _autoPingTimer = AutoPingTimer(); + _rxWindowTimer = RxWindowTimer(); + _discoveryWindowTimer = DiscoveryWindowTimer(); + _timerListenable = Listenable.merge([ + _cooldownTimer, + _manualPingCooldownTimer, + _autoPingTimer, + _rxWindowTimer, + _discoveryWindowTimer, + ]); + if (_liveActivityService.isSupportedPlatform) { + _timerListenable.addListener(_handleLiveActivityTimerChange); + _timerListenerAttached = true; + } + if (_androidAuto.isSupportedPlatform) { + _androidAuto.attach( + snapshotBuilder: _buildWatchSnapshot, + commandHandler: _handleAutoCommand, + onRefusal: (reason) => _emitWatchFailure(reason.compactText), + carMapSettingsBuilder: () => CarMapSettings.from( + styleUrl: + MapStyleExtension.fromString(_preferences.mapStyle).styleUrl, + zoneCode: zoneCode, + tilesEnabled: _preferences.mapTilesEnabled, + gridSize: _preferences.coverageGridSize, + colorVisionType: _preferences.colorVisionType, + opacity: _preferences.coverageOverlayOpacity, + mapAlwaysNorth: _preferences.mapAlwaysNorth, + mapRotationLocked: _preferences.mapRotationLocked, + markerStyle: _preferences.gpsMarkerStyle, + markerFacesHeading: + gpsMarkerFacesHeading(_preferences.gpsMarkerStyle), + nodeName: displayDeviceName, + ), + ); + } + if (_watchBridge.isSupportedPlatform) { + _watchBridge.diagnostics.addListener(_handleWatchDiagnosticsChanged); + _watchBridge.attachCommandHandler( + _handleWatchCommand, + onRefusal: _emitWatchFailure, + // Availability can become true long after provider startup when a + // watch is paired or its app is installed. Push the current state then + // rather than waiting for an unrelated phone-side notification. + onAvailabilityChanged: (available) { + if (available) _scheduleWatchSync(immediate: true); + }, + ); + } + if (_appIntentBridge.isSupportedPlatform) { + _appIntentBridge.attachCommandHandler(_handleSiriCommand); + } + + // Initialize debug logging (enabled by default, respects user preference) + await _initDebugLogs(); + + // Initialize channel service with Public channel only (regional channels added after auth) + await ChannelService.initializePublicChannel(); + debugLog('[APP] Channel service initialized (Public channel only)'); + + // Initialize API queue with error/cleanup callbacks + debugLog('[INIT] Initializing API queue service...'); + _apiQueueService.onPersistenceError = (errorMessage) { + logError(errorMessage); + }; + _apiQueueService.onStorageCleanup = (infoMessage) { + logError(infoMessage); // Log cleanup events to error log so user is aware + }; + await _apiQueueService.init(); + debugLog('[INIT] API queue service initialized'); + _apiQueueService.onQueueUpdated = (size) { + _queueSize = size; + notifyListeners(); + + // Update background service notification with queue size + if (_autoPingEnabled) { + final modeName = _autoMode == AutoMode.passive + ? 'Passive Mode' + : _autoMode == AutoMode.hybrid + ? 'Hybrid Mode' + : _autoMode == AutoMode.targeted + ? 'Trace Mode' + : 'Active Mode'; + BackgroundServiceManager.updateNotification( mode: modeName, txCount: _pingStats.txCount, rxCount: _pingStats.rxCount, @@ -497,22 +3200,28 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } }; - _apiQueueService.onUploadSuccess = (uploadedCount) { + _apiQueueService.onUploadSuccess = (uploadedCount, uploadedItems) { _pingStats = _pingStats.copyWith( successfulUploads: _pingStats.successfulUploads + uploadedCount, ); - debugLog('[APP] Upload success: +$uploadedCount items (total: ${_pingStats.successfulUploads})'); + debugLog( + '[APP] Upload success: +$uploadedCount items (total: ${_pingStats.successfulUploads})'); notifyListeners(); - // Schedule overlay tile refresh after server has time to regenerate tiles - // Cache buster change + notifyListeners triggers flutter_map's reloadImages() - // which updates tile URLs in-place and refetches cleanly - _tileRefreshTimer?.cancel(); - _tileRefreshTimer = Timer(const Duration(seconds: 5), () { - _overlayCacheBust = DateTime.now().millisecondsSinceEpoch; - debugLog('[MAP] Refreshing overlay tiles'); - notifyListeners(); - }); + if (_vectorOverlayActive) { + // Queue the batch's coords for the +7s fresh-tile check; the user's + // own cells land on the map via the session patch (see + // _freshenAffectedVectorTiles). + _pendingFreshZone = zoneCode; + for (final item in uploadedItems) { + if (_pendingFreshCoords.length >= 16) break; + _pendingFreshCoords.add([item.latitude, item.longitude]); + } + _vectorFreshTimer?.cancel(); + _vectorFreshTimer = Timer(const Duration(seconds: 7), () { + _freshenAffectedVectorTiles(attempt: 1); + }); + } }; // Initialize offline session service @@ -533,7 +3242,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Load user preferences debugLog('[INIT] Loading preferences...'); await _loadPreferences(); + await _loadWatchPairingPreference(); await _loadDeviceAntennaPreferences(); + await _loadDevicePowerOverrides(); + await _loadDeviceRealNames(); + await _loadPortalAccountState(); + // Account init is never allowed to abort the rest of startup — everything + // after this point (GPS restore, BLE listeners, auto-connect) matters more + // than the portal lane. + try { + await _portalAccountService.init(); + } catch (e) { + debugWarn('[ACCOUNT] Init failed: ${e.runtimeType}'); + } // Load last known GPS position for map centering await _loadLastPosition(); @@ -543,7 +3264,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen to Bluetooth adapter state changes (on/off) debugLog('[INIT] Setting up Bluetooth adapter state listener...'); - _adapterStateSubscription = _bluetoothService.adapterStateStream.listen((state) { + _adapterStateSubscription = + _bluetoothService.adapterStateStream.listen((state) { final previousState = _bluetoothAdapterState; _bluetoothAdapterState = state; @@ -558,7 +3280,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen to Bluetooth connection changes debugLog('[INIT] Setting up BLE connection listener...'); - _bluetoothService.connectionStream.listen((status) async { + await _connectionSubscription?.cancel(); + _connectionSubscription = + _bluetoothService.connectionStream.listen((status) async { _connectionStatus = status; if (status == ConnectionStatus.disconnected) { // Check if this is an unexpected disconnect during active wardriving @@ -566,15 +3290,27 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final hasRemembered = _rememberedDevice != null; final isUnexpected = !_userRequestedDisconnect && !_isAutoReconnecting; - if (wasConnected && hasRemembered && isUnexpected && !kIsWeb) { - debugLog('[CONN] Unexpected BLE disconnect detected - starting auto-reconnect'); + if (_isInZoneGracePeriod) { + // BLE disconnected during zone grace period — abandon grace, full cleanup + debugLog( + '[CONN] BLE disconnect during zone grace period — full cleanup'); + _cancelZoneGraceTimers(); + _isInZoneGracePeriod = false; + _zoneGraceSecondsRemaining = 0; + if (_autoPingWasEnabledBeforeGrace) _playDisconnectAlert(); + _autoPingWasEnabledBeforeGrace = false; + await _fullDisconnectCleanup(); + } else if (wasConnected && hasRemembered && isUnexpected && !kIsWeb) { + debugLog( + '[CONN] Unexpected BLE disconnect detected - starting auto-reconnect'); await _startAutoReconnect(); } else if (!_isAutoReconnecting) { // Normal disconnect (user-requested or no remembered device) await _fullDisconnectCleanup(); } else { // Disconnected during a reconnect attempt - _attemptReconnect handles retry - debugLog('[CONN] BLE disconnect during reconnect attempt - will retry'); + debugLog( + '[CONN] BLE disconnect during reconnect attempt - will retry'); } } notifyListeners(); @@ -582,7 +3318,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Listen to GPS changes debugLog('[INIT] Setting up GPS status listener...'); - _gpsService.statusStream.listen((status) { + await _gpsStatusSubscription?.cancel(); + _gpsStatusSubscription = _gpsService.statusStream.listen((status) { final previousStatus = _gpsStatus; _gpsStatus = status; @@ -592,26 +3329,41 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Log when we transition to locked state (permission granted + GPS available) if (status == GpsStatus.locked) { - debugLog('[GPS] GPS lock acquired - zone check should trigger on first position'); + debugLog( + '[GPS] GPS lock acquired - zone check should trigger on first position'); } // Log when permission is denied or GPS disabled if (status == GpsStatus.permissionDenied) { - debugLog('[GPS] Location permission denied - zone checks will be blocked'); + debugLog( + '[GPS] Location permission denied - zone checks will be blocked'); } else if (status == GpsStatus.disabled) { - debugLog('[GPS] Location services disabled - zone checks will be blocked'); + debugLog( + '[GPS] Location services disabled - zone checks will be blocked'); } } notifyListeners(); }); - _gpsStatus = _gpsService.status; // Sync initial status + _gpsStatus = _gpsService.status; // Sync initial status debugLog('[INIT] Initial GPS status: $_gpsStatus'); debugLog('[INIT] Setting up GPS position listener...'); - _gpsService.positionStream.listen((position) async { + await _gpsPositionSubscription?.cancel(); + _gpsPositionSubscription = + _gpsService.positionStream.listen((position) async { _currentPosition = position; + // Do NOT bump mapRevision here. Position drives the camera/puck/coords + // directly (MapWidget._onPositionNotify listener + a Selector on the + // GPS-info overlay) — all real-time — WITHOUT rebuilding the map, which + // would relayout the iOS platform view (~28ms) every GPS tick. A plain + // notifyListeners() reaches those position watchers; the map's Selector + // (keyed on mapRevision) stays cached. notifyListeners(); - // Save last position for next app launch + // Diagnostic: catch a stuck countdown timer (the intermittent ping-control + // lockout) in the foreground. Throttled to 5s; logs only when stuck. + _maybeLogStuckTimers(); + + // Save last position for next app launch (already throttled to 30s) _saveLastPosition(position.latitude, position.longitude); // Check zone on first GPS lock (when _inZone is null) @@ -620,7 +3372,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[GEOFENCE] First GPS lock, triggering zone check'); await checkZoneStatus(); _firstGpsLockLogged = true; - } else if (_inZone == null && _preferences.offlineMode && !_firstGpsLockLogged) { + } else if (_inZone == null && + _preferences.offlineMode && + !_firstGpsLockLogged) { debugLog('[GEOFENCE] First GPS lock skipped: offline mode enabled'); _firstGpsLockLogged = true; } @@ -628,8 +3382,26 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Check zone every 100m movement (while disconnected) // This allows users to know if they've entered/exited a zone while moving // Skip zone checks when offline mode is enabled - if (!isConnected && !_preferences.offlineMode && _shouldRecheckZone(position)) { - debugLog('[GEOFENCE] Moved 100m+ while disconnected, rechecking zone'); + if (!isConnected && + !_preferences.offlineMode && + _shouldRecheckZone(position)) { + // Throttle log to once per 30s to avoid spam while driving + final now = DateTime.now(); + if (_lastZoneCheckLogTime == null || + now.difference(_lastZoneCheckLogTime!) >= + const Duration(seconds: 30)) { + if (_zoneCheckSuppressedCount > 0) { + debugLog( + '[GEOFENCE] Moved 100m+ while disconnected, rechecking zone (suppressed $_zoneCheckSuppressedCount similar in last 30s)'); + } else { + debugLog( + '[GEOFENCE] Moved 100m+ while disconnected, rechecking zone'); + } + _lastZoneCheckLogTime = now; + _zoneCheckSuppressedCount = 0; + } else { + _zoneCheckSuppressedCount++; + } await checkZoneStatus(); } @@ -667,15 +3439,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 'isCheckingZone=$_isCheckingZone, hasPosition=${_currentPosition != null}'); await _gpsService.startWatching(); - _gpsStatus = _gpsService.status; // Sync after restart + _gpsStatus = _gpsService.status; // Sync after restart debugLog('[GPS] GPS restarted, new status: $_gpsStatus'); - debugLog('[GPS] Post-restart state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' + debugLog( + '[GPS] Post-restart state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' 'hasPosition=${_currentPosition != null}'); // If we now have a position and zone hasn't been checked, trigger check - if (_currentPosition != null && _inZone == null && !_preferences.offlineMode) { - debugLog('[GPS] Permission granted with existing position - triggering zone check'); + if (_currentPosition != null && + _inZone == null && + !_preferences.offlineMode) { + debugLog( + '[GPS] Permission granted with existing position - triggering zone check'); await checkZoneStatus(); } notifyListeners(); @@ -733,7 +3509,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (!isEnabled) { debugLog('[SCAN] Bluetooth still disabled after retries'); - _connectionError = 'Bluetooth is disabled. Please enable Bluetooth and try again.'; + _connectionError = + 'Bluetooth is disabled. Please enable Bluetooth and try again.'; notifyListeners(); return; } @@ -742,26 +3519,31 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _discoveredDevices = []; _connectionError = null; _isAuthError = false; + _isNetworkError = false; notifyListeners(); // Listen for discovered devices using subscription so stopScan() can cancel DiscoveredDevice? selectedDevice; final completer = Completer(); - _activeScanSubscription = _bluetoothService.scanForDevices( + _activeScanSubscription = _bluetoothService + .scanForDevices( timeout: const Duration(seconds: 15), - ).listen( + ) + .listen( (device) { if (!_discoveredDevices.any((d) => d.id == device.id)) { // Prefer remembered device name (from SelfInfo) over BLE cache var enrichedDevice = device; - if (_rememberedDevice != null && device.id == _rememberedDevice!.id && + if (_rememberedDevice != null && + device.id == _rememberedDevice!.id && device.name != _rememberedDevice!.name) { enrichedDevice = DiscoveredDevice( id: device.id, name: _rememberedDevice!.name, rssi: device.rssi, ); - debugLog('[SCAN] Using remembered name "${_rememberedDevice!.name}" instead of BLE name "${device.name}"'); + debugLog( + '[SCAN] Using remembered name "${_rememberedDevice!.name}" instead of BLE name "${device.name}"'); } _discoveredDevices.add(enrichedDevice); selectedDevice = enrichedDevice; @@ -791,749 +3573,1665 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } - /// Stop scanning for devices - Future stopScan() async { - await _activeScanSubscription?.cancel(); - _activeScanSubscription = null; - await _bluetoothService.stopScan(); - _isScanning = false; + /// Stop scanning for devices + Future stopScan() async { + await _activeScanSubscription?.cancel(); + _activeScanSubscription = null; + await _bluetoothService.stopScan(); + _isScanning = false; + notifyListeners(); + } + + // ============================================ + // Connection + // ============================================ + + /// Derive the reported power from the radio that is connecting, before /auth + /// is told about it. + /// + /// _postConnectionSetup does this too, but it runs after + /// MeshCoreConnection.connect() returns, and /auth is Step 6 *inside* that + /// call. So on the first connect with a different radio the session opened + /// carrying the previous radio's wattage, alongside the new radio's model + /// string. It looked fixed on the next connect only because the preference + /// had been corrected by then (#426). + /// + /// Precedence matches _postConnectionSetup: the model's rating, then the + /// user's saved override for this radio. With neither, the power is not + /// configured, and saying so is what makes the app ask instead of quietly + /// reporting whatever the last radio used. + void _applyConnectingDevicePower(String? deviceName) { + final model = _meshCoreConnection?.deviceModel; + final overrideKey = _isAnonymousRenamed ? _originalDeviceName : deviceName; + final saved = + overrideKey == null ? null : _devicePowerOverrides[overrideKey]; + + if (saved != null) { + _preferences = _preferences.copyWith( + powerLevel: (saved['powerLevel'] as num).toDouble(), + txPower: (saved['txPower'] as num).toInt(), + autoPowerSet: false, + powerLevelSet: true, + ); + debugLog('[MODEL] Reporting saved override for "$overrideKey": ' + '${saved['powerLevel']}W'); + } else if (model != null) { + _preferences = _preferences.copyWith( + powerLevel: model.power, + txPower: model.txPower, + autoPowerSet: true, + powerLevelSet: false, + ); + debugLog( + '[MODEL] Reporting ${model.power}W for ${model.shortName} at auth'); + } else { + // Unrecognized radio with nothing saved. Carrying the previous device's + // flags here would both report its wattage and suppress the prompt that + // asks the user to set one. + _preferences = _preferences.copyWith( + autoPowerSet: false, + powerLevelSet: false, + ); + debugWarn('[MODEL] Unrecognized radio and no saved power, asking the ' + 'user rather than reusing the last one'); + } + notifyListeners(); + } + + /// Creates the two-stage auth callback for MeshCoreConnection Step 6. + /// Shared by all transport types (BLE, TCP, USB Serial). + Future?> Function() _createAuthCallback() { + return () async { + final publicKey = _meshCoreConnection!.devicePublicKey; + if (publicKey == null) { + debugError('[APP] Cannot request auth: no public key'); + return { + 'success': false, + 'reason': 'no_public_key', + 'message': 'Device public key not available' + }; + } + + // Anonymous mode: rename device before auth so mesh pings broadcast as "Anonymous" + if (_preferences.anonymousMode && !_isAnonymousRenamed) { + final realName = _meshCoreConnection!.selfInfo?.name; + if (realName != null && realName.isNotEmpty) { + if (realName == 'Anonymous') { + final persisted = _deviceRealNames[publicKey]; + _originalDeviceName = persisted ?? realName; + if (persisted != null) { + debugLog( + '[CONN] Anonymous mode: recovered real name "$persisted" from Hive (firmware was stuck)'); + } + } else { + _originalDeviceName = realName; + } + try { + await _meshCoreConnection!.setAdvertName('Anonymous'); + _isAnonymousRenamed = true; + _displayDeviceName = 'Anonymous'; + if (_originalDeviceName != 'Anonymous') { + _deviceRealNames[publicKey] = _originalDeviceName!; + _saveDeviceRealNames(); + } + debugLog( + '[CONN] Anonymous mode: renamed from "$_originalDeviceName" to "Anonymous"'); + await Future.delayed(const Duration(milliseconds: 300)); + } catch (e) { + debugError('[CONN] Anonymous mode: rename failed: $e'); + } + } + } + + // Resolve device name: use "Anonymous" if renamed, otherwise SelfInfo name + String? deviceName; + if (_isAnonymousRenamed) { + deviceName = 'Anonymous'; + } else { + var selfInfoName = _meshCoreConnection!.selfInfo?.name; + if (selfInfoName == 'Anonymous') { + final persistedName = _deviceRealNames[publicKey]; + if (persistedName != null) { + debugLog( + '[CONN] Detected stuck anonymous name, recovering to "$persistedName"'); + try { + await _meshCoreConnection!.setAdvertName(persistedName); + await Future.delayed(const Duration(milliseconds: 300)); + final refreshed = await _meshCoreConnection!.getSelfInfo(); + selfInfoName = refreshed.name; + debugLog( + '[CONN] Confirmed firmware name restored to "$selfInfoName"'); + _clearPersistedRealName(publicKey); + } catch (e) { + debugError('[CONN] Failed to restore firmware name: $e'); + selfInfoName = persistedName; + } + } else { + debugWarn( + '[CONN] Firmware name is "Anonymous" but no persisted real name found'); + } + } + deviceName = + selfInfoName ?? connectedDeviceName?.replaceFirst('MeshCore-', ''); + } + if (deviceName == null || deviceName.isEmpty) { + debugError('[APP] Cannot request auth: could not retrieve device name'); + return { + 'success': false, + 'reason': 'no_device_name', + 'message': 'Could not retrieve device name' + }; + } + + // The radio is identified by now, so report ITS power, not whatever the + // last one left in preferences (#426). + _applyConnectingDevicePower(deviceName); + + // Stage 1: Try existing public_key authentication + debugLog( + '[APP] Stage 1: Attempting auth with public_key: ${publicKey.substring(0, 16)}...'); + + final result = await _apiService.requestAuth( + reason: 'connect', + publicKey: publicKey, + who: deviceName, + appVersion: _appVersion, + power: _preferences.powerLevel, + iataCode: zoneCode ?? _preferences.iataCode, + model: _meshCoreConnection!.deviceModel?.manufacturer ?? + _meshCoreConnection!.deviceInfo?.manufacturer ?? + 'Unknown', + radioFreq: _meshCoreConnection?.selfInfo?.radioConfigApi, + lat: _currentPosition?.latitude, + lon: _currentPosition?.longitude, + accuracyMeters: _currentPosition?.accuracy, + ); + + if (result != null && result['maintenance'] == true) { + _maintenanceMode = true; + _maintenanceMessage = result['maintenance_message'] as String?; + _maintenanceUrl = result['maintenance_url'] as String?; + debugLog( + '[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); + _startMaintenancePolling(); + notifyListeners(); + return { + 'success': false, + 'reason': 'maintenance', + 'message': _maintenanceMessage ?? 'Service is under maintenance', + }; + } + + if (result != null && result['success'] == true) { + debugLog('[APP] Stage 1 succeeded: authenticated via public_key'); + if (result['type'] != null) { + _authType = result['type'] as String; + debugLog('[APP] Auth type: $_authType'); + notifyListeners(); + } + _syncZoneCapacityFromAuth(result); + return result; + } + + if (result == null) { + debugError('[APP] API unreachable - network error'); + return { + 'success': false, + 'reason': 'network_error', + 'message': 'Unable to reach the MeshMapper server', + }; + } + + debugLog('[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); + + final stage1Reason = result['reason'] as String?; + if (stage1Reason == 'gps_inaccurate' || stage1Reason == 'gps_stale') { + debugError( + '[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); + return { + 'success': false, + 'reason': stage1Reason, + 'message': result['message'] as String?, + }; + } + + // Stage 2: Auth failed, attempt registration via signed contact_uri + debugLog('[APP] Stage 2: Attempting registration via contact_uri...'); + + String? contactUri; + try { + debugLog('[APP] Requesting signed contact URI from device...'); + contactUri = await _meshCoreConnection!.exportContact(); + debugLog( + '[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); + } catch (e) { + debugError('[APP] Failed to get contact URI from device: $e'); + return { + 'success': false, + 'reason': 'registration_failed', + 'message': + 'Companion not found in backend and failed to register via API' + }; + } + + final registerResult = await _apiService.requestAuth( + reason: 'register', + contactUri: contactUri, + who: deviceName, + appVersion: _appVersion, + power: _preferences.powerLevel, + iataCode: zoneCode ?? _preferences.iataCode, + model: _meshCoreConnection!.deviceModel?.manufacturer ?? + _meshCoreConnection!.deviceInfo?.manufacturer ?? + 'Unknown', + radioFreq: _meshCoreConnection?.selfInfo?.radioConfigApi, + lat: _currentPosition?.latitude, + lon: _currentPosition?.longitude, + accuracyMeters: _currentPosition?.accuracy, + ); + + if (registerResult == null) { + debugError('[APP] Stage 2 failed: network error (API unreachable)'); + return { + 'success': false, + 'reason': 'network_error', + 'message': 'Unable to reach the MeshMapper server', + }; + } + + if (registerResult['success'] != true) { + final serverReason = + registerResult['reason'] as String? ?? 'registration_failed'; + final serverMessage = registerResult['message'] as String?; + debugError( + '[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); + + // Diagnose stale ADVERT timestamp: the firmware refuses to set the + // clock backwards (ERR code 6), so if the device's RTC is stuck in + // the future the signed ADVERT will always be rejected by the server. + // Query the device clock and surface an actionable error. + if (serverMessage != null && + serverMessage.contains('Timestamp') && + _meshCoreConnection != null) { + try { + final deviceTime = await _meshCoreConnection!.getDeviceTime(); + final appTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final drift = deviceTime - appTime; + debugError( + '[APP] Device clock: $deviceTime, app clock: $appTime, drift: ${drift}s'); + if (drift > 3600) { + final deviceDate = DateTime.fromMillisecondsSinceEpoch( + deviceTime * 1000, + isUtc: true); + return { + 'success': false, + 'reason': 'clock_error', + 'message': + 'Device clock is set to ${deviceDate.toIso8601String().substring(0, 10)}. ' + 'Power-cycle your device to reset it.', + }; + } + } catch (e) { + debugWarn('[APP] Could not query device time: $e'); + } + } + + return { + 'success': false, + 'reason': serverReason, + 'message': serverMessage ?? 'Registration rejected by server', + }; + } + + debugLog('[APP] Stage 2 succeeded: registered and authenticated'); + if (registerResult['type'] != null) { + _authType = registerResult['type'] as String; + debugLog('[APP] Auth type: $_authType'); + notifyListeners(); + } + _syncZoneCapacityFromAuth(registerResult); + return registerResult; + }; + } + + /// Handle connection errors — shared by all transport connection methods. + Future _handleConnectionError(Object e) async { + debugError('[APP] Connection failed: $e'); + + try { + await _meshCoreConnection?.deleteWardrivingChannelEarly(); + } catch (channelError) { + debugError('[APP] Cleanup channel delete failed: $channelError'); + } + + try { + if (_meshCoreConnection != null) { + await _meshCoreConnection!.disconnect(); + } + } catch (disconnectError) { + debugError('[APP] Cleanup disconnect failed: $disconnectError'); + } + + final errorStr = e.toString(); + if (errorStr.contains('AUTH_FAILED:')) { + _isAuthError = true; + final parts = errorStr.split('AUTH_FAILED:'); + if (parts.length > 1) { + final errorParts = parts[1].split(':'); + final reason = errorParts.isNotEmpty ? errorParts[0] : 'unknown'; + final serverMessage = + errorParts.length > 1 ? errorParts.sublist(1).join(':') : null; + _isNetworkError = reason == 'network_error'; + _connectionError = _getErrorMessage(reason, serverMessage); + } else { + _connectionError = 'Authentication failed'; + } + } else { + _isAuthError = false; + _isNetworkError = false; + if (errorStr.contains('timeout') || + errorStr.contains('Timeout') || + errorStr.contains('timed out')) { + _connectionError = 'Connection timed out'; + } else { + _connectionError = errorStr.replaceFirst('Exception: ', ''); + } + } + _isConnecting = false; + _connectionStep = ConnectionStep.error; + notifyListeners(); + } + + /// Set up disconnect listener for non-BLE transports (TCP, USB Serial). + void _setupTransportDisconnectListener(CompanionTransport transport) { + _transportConnectionSubscription?.cancel(); + _transportConnectionSubscription = + transport.connectionStream.listen((status) async { + if (status == ConnectionStatus.disconnected) { + final wasConnected = _connectionStep == ConnectionStep.connected; + final hasRemembered = _rememberedDevice != null; + final isUnexpected = !_userRequestedDisconnect && !_isAutoReconnecting; + final canAutoReconnect = hasRemembered && + !kIsWeb && + _rememberedDevice!.transportType != TransportType.usbSerial; + if (wasConnected && isUnexpected && canAutoReconnect) { + debugLog( + '[CONN] Unexpected transport disconnect - starting auto-reconnect'); + await _startAutoReconnect(); + } else if (!_isAutoReconnecting) { + await _fullDisconnectCleanup(); + } + notifyListeners(); + } + }); + } + + /// Connect to a discovered device + Future connectToDevice(DiscoveredDevice device) async { + if (_isConnecting) { + debugLog('[APP] Connection already in progress, ignoring duplicate tap'); + return; + } + _isConnecting = true; + final myGeneration = ++_connectGeneration; + _connectionStep = ConnectionStep.transportConnecting; + _connectionError = null; + _isAuthError = false; + _isNetworkError = false; + notifyListeners(); + + // #500: a handshake that dies right after the transport connects gets one + // automatic re-run of the whole workflow, the same thing the manual re-tap + // that users report always works would do. + var workflowRerunUsed = false; + while (true) { + DateTime? transportConnectedAt; + try { + // Clean up any previous connection first + if (_meshCoreConnection != null) { + debugLog('[APP] Disposing previous MeshCoreConnection'); + _meshCoreConnection!.dispose(); + _meshCoreConnection = null; + } + + // ALWAYS START FRESH - clear any stale pings before connecting + await _apiQueueService.clearBeforeConnect(); + + debugLog('[APP] Connecting BLE transport to ${device.id}'); + // During auto-reconnect the internal retry loop is capped to one + // attempt: the reconnect ladder retries on its own, and a 3x15s + // internal loop would eat the whole 30s reconnect budget in one call. + await _bluetoothService.connect(device.id, + maxAttempts: _isAutoReconnecting ? 1 : null); + transportConnectedAt = DateTime.now(); + _activeTransport = _bluetoothService; + debugLog('[APP] Creating new MeshCoreConnection'); + _meshCoreConnection = MeshCoreConnection(transport: _bluetoothService); + + if (!_preferences.offlineMode) { + _meshCoreConnection!.onRequestAuth = _createAuthCallback(); + } else { + _meshCoreConnection!.onRequestAuth = null; + debugLog('[APP] Offline mode: skipping API auth'); + } + + // Listen for step changes + _meshCoreConnection!.stepStream.listen((step) { + _connectionStep = step; + if (step == ConnectionStep.connected) { + // Update device info + _manufacturerString = _meshCoreConnection!.deviceInfo?.manufacturer; + _firmwareVersionString = + _meshCoreConnection!.deviceInfo?.firmwareVersionString; + _deviceModel = _meshCoreConnection!.deviceModel; + _devicePublicKey = _meshCoreConnection!.devicePublicKey; + debugLog( + '[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); + + // Persist device info for bug reports when disconnected + // Use original name (not "Anonymous") for bug report identification + var lastDeviceName = _isAnonymousRenamed + ? _originalDeviceName + : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName); + if (lastDeviceName != null) { + lastDeviceName = lastDeviceName.replaceFirst('MeshCore-', ''); + } + // Cascade guard: never persist "Anonymous" as the last connected device + if (lastDeviceName == 'Anonymous' && _devicePublicKey != null) { + lastDeviceName = + _deviceRealNames[_devicePublicKey!] ?? lastDeviceName; + } + if (lastDeviceName != null && + lastDeviceName.isNotEmpty && + _devicePublicKey != null) { + _saveLastConnectedDevice(lastDeviceName, _devicePublicKey!); + } + + // In offline mode, fetch signed contact URI for later registration during upload + if (_preferences.offlineMode && _meshCoreConnection != null) { + _meshCoreConnection!.exportContact().then((uri) { + _offlineContactUri = uri; + debugLog('[OFFLINE] Stored contact URI for offline session'); + }).catchError((e) { + debugWarn('[OFFLINE] Failed to get contact URI: $e'); + }); + } + } + notifyListeners(); + }); + + // Listen for noise floor updates — only rebuild UI when value changes + _noiseFloorSubscription = + _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { + _recordNoiseFloorSample(noiseFloor); + if (noiseFloor != _currentNoiseFloor) { + _currentNoiseFloor = noiseFloor; + notifyListeners(); + } + }); + + // Listen for battery updates — only rebuild UI when value changes + _batterySubscription = + _meshCoreConnection!.batteryStream.listen((batteryPercent) { + if (batteryPercent != _currentBatteryPercent) { + _currentBatteryPercent = batteryPercent; + notifyListeners(); + } + }); + + // Execute connection workflow (transport already connected above) + final connectionResult = await _meshCoreConnection!.connect( + _deviceModelService.models, + ); + + await _postConnectionSetup(connectionResult, device); + _isConnecting = false; + return; + } catch (e) { + if (myGeneration != _connectGeneration) { + // A newer connection attempt owns the transport and the shared + // provider state; this stale attempt must not touch either. + debugLog('[CONN] Superseded connection attempt failed, ignoring: $e'); + return; + } + final sinceTransportConnect = transportConnectedAt == null + ? null + : DateTime.now().difference(transportConnectedAt); + if (!shouldRerunConnectionWorkflow( + transportConnected: transportConnectedAt != null, + sinceTransportConnect: sinceTransportConnect, + errorString: e.toString(), + isAutoReconnecting: _isAutoReconnecting, + userRequestedDisconnect: _userRequestedDisconnect, + alreadyRerun: workflowRerunUsed, + )) { + await _handleConnectionError(e); + return; + } + workflowRerunUsed = true; + debugLog( + '[CONN] Handshake died ${sinceTransportConnect!.inSeconds}s after transport connect, re-running connection workflow once: $e'); + // The failed run's transport disconnect emits a 'disconnected' event + // whose listener starts _fullDisconnectCleanup. That event was queued + // before this timer, and microtasks drain before timers fire, so after + // the delay the tracked future (if any) is the cleanup for THIS + // failure. Waiting it out means the cleanup cannot tear down the + // re-run's fresh connection. + await Future.delayed(const Duration(milliseconds: 100)); + final cleanupInFlight = _fullCleanupInFlight; + if (cleanupInFlight != null) { + await cleanupInFlight; + } + if (myGeneration != _connectGeneration) { + debugLog( + '[CONN] Skipping workflow re-run: a newer connection attempt owns the transport'); + return; + } + if (_isConnecting) { + // No disconnect-event cleanup ran for this failure: the transport is + // still up (e.g. _postConnectionSetup threw on a live link), so + // there is nothing safe to re-run on top of. Hand the error to the + // normal failure path instead of swallowing it. + await _handleConnectionError(e); + return; + } + _isConnecting = true; + _connectionStep = ConnectionStep.transportConnecting; + _connectionError = null; + // The disconnect cleanup cleared the BLE scan cache; restore the + // device name for the re-run. + _bluetoothService.cacheDeviceInfo(device); + notifyListeners(); + } + } + } + + /// Set the selected transport type for the connection screen. + void setSelectedTransport(TransportType type) { + _selectedTransport = type; notifyListeners(); } - // ============================================ - // Connection - // ============================================ - - /// Connect to a discovered device - Future connectToDevice(DiscoveredDevice device) async { + /// Connect to a MeshCore device via TCP. + Future connectViaTcp(String host, int port) async { + if (_isConnecting) { + debugLog('[APP] Connection already in progress, ignoring duplicate tap'); + return; + } + _isConnecting = true; + _connectGeneration++; // invalidate any pending BLE workflow re-run + _connectionStep = ConnectionStep.transportConnecting; + _connectionError = null; + _isAuthError = false; + _isNetworkError = false; + notifyListeners(); try { - _connectionError = null; - _isAuthError = false; - - // Clean up any previous connection first if (_meshCoreConnection != null) { debugLog('[APP] Disposing previous MeshCoreConnection'); _meshCoreConnection!.dispose(); _meshCoreConnection = null; } - // ALWAYS START FRESH - clear any stale pings before connecting await _apiQueueService.clearBeforeConnect(); - // Create MeshCore connection - debugLog('[APP] Creating new MeshCoreConnection'); - _meshCoreConnection = MeshCoreConnection(bluetooth: _bluetoothService); + final tcpService = TcpService(host: host, port: port); + debugLog('[APP] Connecting TCP transport to $host:$port'); + await tcpService.openConnection(); + _activeTransport = tcpService; + _setupTransportDisconnectListener(tcpService); + + debugLog('[APP] Creating new MeshCoreConnection (TCP)'); + _meshCoreConnection = MeshCoreConnection(transport: tcpService); - // Set auth callback for Step 6 (called during connect, after public key is acquired) - // Implements two-stage auth flow with registration fallback - // Skip auth when offline mode is enabled if (!_preferences.offlineMode) { - _meshCoreConnection!.onRequestAuth = () async { - final publicKey = _meshCoreConnection!.devicePublicKey; - if (publicKey == null) { - debugError('[APP] Cannot request auth: no public key'); - return {'success': false, 'reason': 'no_public_key', 'message': 'Device public key not available'}; - } + _meshCoreConnection!.onRequestAuth = _createAuthCallback(); + } else { + _meshCoreConnection!.onRequestAuth = null; + debugLog('[APP] Offline mode: skipping API auth'); + } - // Anonymous mode: rename device before auth so mesh pings broadcast as "Anonymous" - if (_preferences.anonymousMode && !_isAnonymousRenamed) { - final realName = _meshCoreConnection!.selfInfo?.name; - if (realName != null && realName.isNotEmpty) { - _originalDeviceName = realName; - try { - await _meshCoreConnection!.setAdvertName('Anonymous'); - _isAnonymousRenamed = true; - _displayDeviceName = 'Anonymous'; - debugLog('[CONN] Anonymous mode: renamed from "$realName" to "Anonymous"'); - // Short delay for firmware to process - await Future.delayed(const Duration(milliseconds: 300)); - } catch (e) { - debugError('[CONN] Anonymous mode: rename failed: $e'); - // Continue with real name if rename fails - } - } + _meshCoreConnection!.stepStream.listen((step) { + _connectionStep = step; + if (step == ConnectionStep.connected) { + _manufacturerString = _meshCoreConnection!.deviceInfo?.manufacturer; + _firmwareVersionString = + _meshCoreConnection!.deviceInfo?.firmwareVersionString; + _deviceModel = _meshCoreConnection!.deviceModel; + _devicePublicKey = _meshCoreConnection!.devicePublicKey; + debugLog( + '[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); + + var lastDeviceName = _isAnonymousRenamed + ? _originalDeviceName + : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName); + if (lastDeviceName != null) { + lastDeviceName = lastDeviceName.replaceFirst('MeshCore-', ''); + } + if (lastDeviceName == 'Anonymous' && _devicePublicKey != null) { + lastDeviceName = + _deviceRealNames[_devicePublicKey!] ?? lastDeviceName; + } + if (lastDeviceName != null && + lastDeviceName.isNotEmpty && + _devicePublicKey != null) { + _saveLastConnectedDevice(lastDeviceName, _devicePublicKey!); } - // Resolve device name: use "Anonymous" if renamed, otherwise SelfInfo name - final deviceName = _isAnonymousRenamed - ? 'Anonymous' - : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); - if (deviceName == null || deviceName.isEmpty) { - debugError('[APP] Cannot request auth: could not retrieve device name'); - return {'success': false, 'reason': 'no_device_name', 'message': 'Could not retrieve device name'}; + if (_preferences.offlineMode && _meshCoreConnection != null) { + _meshCoreConnection!.exportContact().then((uri) { + _offlineContactUri = uri; + debugLog('[OFFLINE] Stored contact URI for offline session'); + }).catchError((e) { + debugWarn('[OFFLINE] Failed to get contact URI: $e'); + }); } + } + notifyListeners(); + }); - // ============================================================ - // STAGE 1: Try existing public_key authentication - // ============================================================ - debugLog('[APP] Stage 1: Attempting auth with public_key: ${publicKey.substring(0, 16)}...'); + _noiseFloorSubscription = + _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { + _recordNoiseFloorSample(noiseFloor); + if (noiseFloor != _currentNoiseFloor) { + _currentNoiseFloor = noiseFloor; + notifyListeners(); + } + }); - final result = await _apiService.requestAuth( - reason: 'connect', - publicKey: publicKey, - who: deviceName, - appVersion: _appVersion, - power: _preferences.powerLevel, - iataCode: zoneCode ?? _preferences.iataCode, - model: _meshCoreConnection!.deviceModel?.manufacturer ?? _meshCoreConnection!.deviceInfo?.manufacturer ?? 'Unknown', - lat: _currentPosition?.latitude, - lon: _currentPosition?.longitude, - accuracyMeters: _currentPosition?.accuracy, - ); + _batterySubscription = + _meshCoreConnection!.batteryStream.listen((batteryPercent) { + if (batteryPercent != _currentBatteryPercent) { + _currentBatteryPercent = batteryPercent; + notifyListeners(); + } + }); - // Check for maintenance mode - if (result != null && result['maintenance'] == true) { - _maintenanceMode = true; - _maintenanceMessage = result['maintenance_message'] as String?; - _maintenanceUrl = result['maintenance_url'] as String?; - debugLog('[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); - _startMaintenancePolling(); - notifyListeners(); - return { - 'success': false, - 'reason': 'maintenance', - 'message': _maintenanceMessage ?? 'Service is under maintenance', - }; - } + final connectionResult = await _meshCoreConnection!.connect( + _deviceModelService.models, + ); - // Check if Stage 1 succeeded - if (result != null && result['success'] == true) { - debugLog('[APP] Stage 1 succeeded: authenticated via public_key'); + final device = DiscoveredDevice( + id: '$host:$port', + name: 'TCP $host:$port', + ); + await _postConnectionSetup(connectionResult, device, + tcpHost: host, tcpPort: port); - // Store the auth type from response - if (result['type'] != null) { - _authType = result['type'] as String; - debugLog('[APP] Auth type: $_authType'); - notifyListeners(); - } + await TcpService.saveConnection(host, port, displayDeviceName ?? ''); + _isConnecting = false; + } catch (e) { + await _handleConnectionError(e); + if (_activeTransport != null && _activeTransport != _bluetoothService) { + _activeTransport!.dispose(); + } + _activeTransport = null; + _transportConnectionSubscription?.cancel(); + } + } - return result; - } + /// Connect to a MeshCore device via Android USB Serial (OTG). + Future connectViaUsb(Map usbDevice) async { + if (_isConnecting) { + debugLog('[APP] Connection already in progress, ignoring duplicate tap'); + return; + } + _isConnecting = true; + _connectGeneration++; // invalidate any pending BLE workflow re-run + _connectionStep = ConnectionStep.transportConnecting; + _connectionError = null; + _isAuthError = false; + _isNetworkError = false; + notifyListeners(); + try { + if (_meshCoreConnection != null) { + debugLog('[APP] Disposing previous MeshCoreConnection'); + _meshCoreConnection!.dispose(); + _meshCoreConnection = null; + } - // API unreachable (null = network/timeout error, not an auth rejection) - if (result == null) { - debugError('[APP] API unreachable - network error'); - return { - 'success': false, - 'reason': 'network_error', - 'message': 'Unable to reach the MeshMapper server', - }; - } + if (_activeTransport != null && _activeTransport != _bluetoothService) { + _activeTransport!.dispose(); + } + _activeTransport = null; + _transportConnectionSubscription?.cancel(); - debugLog('[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); - - // If Stage 1 failed due to GPS issues, Stage 2 will also fail with same bad data - final stage1Reason = result['reason'] as String?; - if (stage1Reason == 'gps_inaccurate' || stage1Reason == 'gps_stale') { - debugError('[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); - return { - 'success': false, - 'reason': stage1Reason, - 'message': result['message'] as String?, - }; - } + await _apiQueueService.clearBeforeConnect(); - // ============================================================ - // STAGE 2: Auth failed, attempt registration via signed contact_uri - // ============================================================ - debugLog('[APP] Stage 2: Attempting registration via contact_uri...'); + final usbProductName = + usbDevice['productName'] as String? ?? 'USB Serial'; + final usbDeviceName = usbDevice['deviceName'] as String? ?? 'USB Serial'; + final serialService = AndroidSerialService( + deviceName: usbDeviceName, + productName: usbProductName, + ); + debugLog('[APP] Connecting USB Serial transport to $usbProductName'); + await serialService.openConnection(); + _activeTransport = serialService; + _setupTransportDisconnectListener(serialService); - String? contactUri; - try { - debugLog('[APP] Requesting signed contact URI from device...'); - contactUri = await _meshCoreConnection!.exportContact(); - debugLog('[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); - } catch (e) { - debugError('[APP] Failed to get contact URI from device: $e'); - return { - 'success': false, - 'reason': 'registration_failed', - 'message': 'Companion not found in backend and failed to register via API' - }; - } + debugLog('[APP] Creating new MeshCoreConnection (USB Serial)'); + _meshCoreConnection = MeshCoreConnection(transport: serialService); - // Call API with contact_uri for registration - final registerResult = await _apiService.requestAuth( - reason: 'register', - contactUri: contactUri, - who: deviceName, - appVersion: _appVersion, - power: _preferences.powerLevel, - iataCode: zoneCode ?? _preferences.iataCode, - model: _meshCoreConnection!.deviceModel?.manufacturer ?? _meshCoreConnection!.deviceInfo?.manufacturer ?? 'Unknown', - lat: _currentPosition?.latitude, - lon: _currentPosition?.longitude, - accuracyMeters: _currentPosition?.accuracy, - ); + if (!_preferences.offlineMode) { + _meshCoreConnection!.onRequestAuth = _createAuthCallback(); + } else { + _meshCoreConnection!.onRequestAuth = null; + debugLog('[APP] Offline mode: skipping API auth'); + } - if (registerResult == null) { - debugError('[APP] Stage 2 failed: network error (API unreachable)'); - return { - 'success': false, - 'reason': 'network_error', - 'message': 'Unable to reach the MeshMapper server', - }; + _meshCoreConnection!.stepStream.listen((step) { + _connectionStep = step; + if (step == ConnectionStep.connected) { + _manufacturerString = _meshCoreConnection!.deviceInfo?.manufacturer; + _firmwareVersionString = + _meshCoreConnection!.deviceInfo?.firmwareVersionString; + _deviceModel = _meshCoreConnection!.deviceModel; + _devicePublicKey = _meshCoreConnection!.devicePublicKey; + debugLog( + '[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); + + var lastDeviceName = _isAnonymousRenamed + ? _originalDeviceName + : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName); + if (lastDeviceName != null) { + lastDeviceName = lastDeviceName.replaceFirst('MeshCore-', ''); + } + if (lastDeviceName == 'Anonymous' && _devicePublicKey != null) { + lastDeviceName = + _deviceRealNames[_devicePublicKey!] ?? lastDeviceName; + } + if (lastDeviceName != null && + lastDeviceName.isNotEmpty && + _devicePublicKey != null) { + _saveLastConnectedDevice(lastDeviceName, _devicePublicKey!); } - if (registerResult['success'] != true) { - final serverReason = registerResult['reason'] as String? ?? 'registration_failed'; - final serverMessage = registerResult['message'] as String?; - debugError('[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); - return { - 'success': false, - 'reason': serverReason, - 'message': serverMessage ?? 'Registration rejected by server', - }; + if (_preferences.offlineMode && _meshCoreConnection != null) { + _meshCoreConnection!.exportContact().then((uri) { + _offlineContactUri = uri; + debugLog('[OFFLINE] Stored contact URI for offline session'); + }).catchError((e) { + debugWarn('[OFFLINE] Failed to get contact URI: $e'); + }); } + } + notifyListeners(); + }); - // Registration successful - response contains full auth data directly - debugLog('[APP] Stage 2 succeeded: registered and authenticated'); + _noiseFloorSubscription = + _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { + _recordNoiseFloorSample(noiseFloor); + if (noiseFloor != _currentNoiseFloor) { + _currentNoiseFloor = noiseFloor; + notifyListeners(); + } + }); - // Store the auth type from response - if (registerResult['type'] != null) { - _authType = registerResult['type'] as String; - debugLog('[APP] Auth type: $_authType'); - notifyListeners(); - } + _batterySubscription = + _meshCoreConnection!.batteryStream.listen((batteryPercent) { + if (batteryPercent != _currentBatteryPercent) { + _currentBatteryPercent = batteryPercent; + notifyListeners(); + } + }); - return registerResult; - }; + final connectionResult = await _meshCoreConnection!.connect( + _deviceModelService.models, + ); + + final vid = usbDevice['vid'] as int? ?? 0; + final pid = usbDevice['pid'] as int? ?? 0; + final serial = usbDevice['serial'] as String? ?? ''; + final deviceId = '$vid:$pid:$serial'; + final device = DiscoveredDevice( + id: deviceId, + name: usbProductName, + ); + await _postConnectionSetup(connectionResult, device, + serialPortPath: deviceId); + _isConnecting = false; + } catch (e) { + await _handleConnectionError(e); + if (_activeTransport != null && _activeTransport != _bluetoothService) { + _activeTransport!.dispose(); + } + _activeTransport = null; + _transportConnectionSubscription?.cancel(); + } + } + + /// Connect using a pre-opened transport (for platform-specific transports + /// like Web Serial that can't be imported cross-platform). + Future connectWithTransport( + CompanionTransport transport, { + required String deviceId, + required String deviceName, + String? serialPortPath, + }) async { + if (_isConnecting) { + debugLog('[APP] Connection already in progress, ignoring duplicate tap'); + return; + } + _isConnecting = true; + _connectGeneration++; // invalidate any pending BLE workflow re-run + _connectionStep = ConnectionStep.transportConnecting; + _connectionError = null; + _isAuthError = false; + _isNetworkError = false; + notifyListeners(); + try { + if (_meshCoreConnection != null) { + debugLog('[APP] Disposing previous MeshCoreConnection'); + _meshCoreConnection!.dispose(); + _meshCoreConnection = null; + } + + await _apiQueueService.clearBeforeConnect(); + + _activeTransport = transport; + _setupTransportDisconnectListener(transport); + + debugLog('[APP] Creating new MeshCoreConnection (generic transport)'); + _meshCoreConnection = MeshCoreConnection(transport: transport); + + if (!_preferences.offlineMode) { + _meshCoreConnection!.onRequestAuth = _createAuthCallback(); } else { - // Offline mode: skip API auth _meshCoreConnection!.onRequestAuth = null; debugLog('[APP] Offline mode: skipping API auth'); } - // Listen for step changes _meshCoreConnection!.stepStream.listen((step) { _connectionStep = step; if (step == ConnectionStep.connected) { - // Update device info _manufacturerString = _meshCoreConnection!.deviceInfo?.manufacturer; + _firmwareVersionString = + _meshCoreConnection!.deviceInfo?.firmwareVersionString; _deviceModel = _meshCoreConnection!.deviceModel; _devicePublicKey = _meshCoreConnection!.devicePublicKey; - debugLog('[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); + debugLog( + '[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); - // Persist device info for bug reports when disconnected - // Use original name (not "Anonymous") for bug report identification - var deviceName = _isAnonymousRenamed + var lastDeviceName = _isAnonymousRenamed ? _originalDeviceName : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName); - if (deviceName != null) { - // Always strip MeshCore- prefix if present - deviceName = deviceName.replaceFirst('MeshCore-', ''); + if (lastDeviceName != null) { + lastDeviceName = lastDeviceName.replaceFirst('MeshCore-', ''); + } + if (lastDeviceName == 'Anonymous' && _devicePublicKey != null) { + lastDeviceName = + _deviceRealNames[_devicePublicKey!] ?? lastDeviceName; + } + if (lastDeviceName != null && + lastDeviceName.isNotEmpty && + _devicePublicKey != null) { + _saveLastConnectedDevice(lastDeviceName, _devicePublicKey!); } - if (deviceName != null && deviceName.isNotEmpty && _devicePublicKey != null) { - _saveLastConnectedDevice(deviceName, _devicePublicKey!); + + if (_preferences.offlineMode && _meshCoreConnection != null) { + _meshCoreConnection!.exportContact().then((uri) { + _offlineContactUri = uri; + debugLog('[OFFLINE] Stored contact URI for offline session'); + }).catchError((e) { + debugWarn('[OFFLINE] Failed to get contact URI: $e'); + }); } } notifyListeners(); }); - // Listen for noise floor updates - _noiseFloorSubscription = _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { - _currentNoiseFloor = noiseFloor; - // Record sample to current noise floor session (if active) + _noiseFloorSubscription = + _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { _recordNoiseFloorSample(noiseFloor); - notifyListeners(); + if (noiseFloor != _currentNoiseFloor) { + _currentNoiseFloor = noiseFloor; + notifyListeners(); + } }); - // Listen for battery updates - _batterySubscription = _meshCoreConnection!.batteryStream.listen((batteryPercent) { - _currentBatteryPercent = batteryPercent; - notifyListeners(); + _batterySubscription = + _meshCoreConnection!.batteryStream.listen((batteryPercent) { + if (batteryPercent != _currentBatteryPercent) { + _currentBatteryPercent = batteryPercent; + notifyListeners(); + } }); - // Execute connection workflow final connectionResult = await _meshCoreConnection!.connect( - device.id, _deviceModelService.models, ); - // Update preferences if device model was recognized (for display/API reporting) - // Note: This does NOT change the radio's TX power - it only sets what power level to REPORT - if (connectionResult.deviceModelMatched && connectionResult.deviceModel != null) { - final device = connectionResult.deviceModel!; - _preferences = _preferences.copyWith( - powerLevel: device.power, - txPower: device.txPower, - autoPowerSet: true, // Indicates power was auto-detected from device model - ); - // TODO: Persist to SharedPreferences when implemented - notifyListeners(); - debugLog('[MODEL] Device recognized: ${device.shortName} - reporting ${device.power}W in API calls'); + final device = DiscoveredDevice(id: deviceId, name: deviceName); + await _postConnectionSetup(connectionResult, device, + serialPortPath: serialPortPath); + _isConnecting = false; + } catch (e) { + await _handleConnectionError(e); + if (_activeTransport != null && _activeTransport != _bluetoothService) { + _activeTransport!.dispose(); } + _activeTransport = null; + _transportConnectionSubscription?.cancel(); + } + } - // Note: API session acquisition is now handled by the auth callback - // during connection workflow Step 6 (onRequestAuth) + /// Post-connection setup shared by all transport types. + /// Called after MeshCoreConnection.connect() completes successfully. + Future _postConnectionSetup( + ({DeviceModel? deviceModel, bool deviceModelMatched}) connectionResult, + DiscoveredDevice device, { + String? tcpHost, + int? tcpPort, + String? serialPortPath, + }) async { + // Per-connection account-link flags. Reset HERE, not in a teardown path: + // this is the ONE point all four transports funnel through, whereas + // disconnect(), _fullDisconnectCleanup() and _startAutoReconnect() each + // miss at least one of the others. + _portalLinkPromptPending = false; + _portalLinkPromptPubkey = null; + + if (connectionResult.deviceModelMatched && + connectionResult.deviceModel != null) { + final matchedDevice = connectionResult.deviceModel!; + _preferences = _preferences.copyWith( + powerLevel: matchedDevice.power, + txPower: matchedDevice.txPower, + autoPowerSet: true, + powerLevelSet: false, + ); + notifyListeners(); + debugLog( + '[MODEL] Device recognized: ${matchedDevice.shortName} - reporting ${matchedDevice.power}W in API calls'); + } - // Create unified RX handler - await _createUnifiedRxHandler(); + await _createUnifiedRxHandler(); - // Set regional channels from API response and update validator - final apiChannels = _apiService.channels; - await ChannelService.setRegionalChannels(apiChannels); - _regionalChannels = ChannelService.getRegionalChannelNames(); - debugLog('[APP] Regional channels configured: $_regionalChannels'); + final apiChannels = _apiService.channels; + await ChannelService.setRegionalChannels(apiChannels); + _regionalChannels = ChannelService.getRegionalChannelNames(); + debugLog('[APP] Regional channels configured: $_regionalChannels'); - // Update unified RX handler's validator with new channel configuration - if (_unifiedRxHandler != null) { - final allowedChannelsData = ChannelService.getAllowedChannelsForValidator(); - final allowedChannels = {}; - for (final entry in allowedChannelsData.entries) { - allowedChannels[entry.key] = ChannelInfo( - channelName: entry.value.channelName, - key: entry.value.key, - hash: entry.value.hash, - ); - } - final newValidator = PacketValidator( - allowedChannels: allowedChannels, - disableRssiFilter: _preferences.disableRssiFilter, + if (_unifiedRxHandler != null) { + final allowedChannelsData = + ChannelService.getAllowedChannelsForValidator(); + final allowedChannels = {}; + for (final entry in allowedChannelsData.entries) { + allowedChannels[entry.key] = ChannelInfo( + channelName: entry.value.channelName, + key: entry.value.key, + hash: entry.value.hash, ); - _unifiedRxHandler!.updateValidator(newValidator); - debugLog('[APP] PacketValidator updated with ${allowedChannels.length} channels: ' - '${allowedChannelsData.values.map((c) => c.channelName).join(', ')}'); } + final newValidator = PacketValidator( + allowedChannels: allowedChannels, + disableRssiFilter: _preferences.disableRssiFilter, + ); + _unifiedRxHandler!.updateValidator(newValidator); + debugLog( + '[APP] PacketValidator updated with ${allowedChannels.length} channels: ' + '${allowedChannelsData.values.map((c) => c.channelName).join(', ')}'); + } - // Set flood scope from API response (regional TX filtering) - // "*" or "#*" = wildcard/global → no scope (unscoped flood, same as before) - // Any other value (e.g., "ottawa") → derive TransportKey and set scope - final apiScopes = _apiService.scopes; - final firstScope = apiScopes.isNotEmpty ? apiScopes.first : null; - final isWildcard = firstScope == null || firstScope == '*' || firstScope == '#*'; - if (!isWildcard) { - final scopeName = firstScope; - _scope = scopeName.startsWith('#') ? scopeName : '#$scopeName'; - final scopeKey = CryptoService.deriveScopeKey(scopeName); - debugLog('[CONN] Setting flood scope: $scopeName'); - await _meshCoreConnection!.setFloodScope(scopeKey); - debugLog('[CONN] Flood scope set successfully'); - } else { - _scope = null; - debugLog('[CONN] No regional scope — using unscoped flood'); - } + final apiScopes = _apiService.scopes; + final firstScope = apiScopes.isNotEmpty ? apiScopes.first : null; + final isWildcard = + firstScope == null || firstScope == '*' || firstScope == '#*'; + if (!isWildcard) { + final scopeName = firstScope; + _scope = scopeName.startsWith('#') ? scopeName : '#$scopeName'; + final scopeKey = CryptoService.deriveScopeKey(scopeName); + debugLog('[CONN] Setting flood scope: $scopeName'); + await _meshCoreConnection!.setFloodScope(scopeKey); + debugLog('[CONN] Flood scope set successfully'); + } else { + _scope = null; + debugLog('[CONN] No regional scope — using unscoped flood'); + } - // Enforce hybrid mode if required by regional admin - if (_apiService.enforceHybrid && !_preferences.hybridModeEnabled) { - _preferences = _preferences.copyWith(hybridModeEnabled: true); - debugLog('[CONN] Hybrid mode force-enabled by regional admin'); - } + // Snapshot user's preferences before zone admin overrides (single baseline) + _userOriginalAutoPingInterval = _preferences.autoPingInterval; + _userOriginalHybridMode = _preferences.hybridModeEnabled; + _userOriginalDiscDrop = _preferences.discDropEnabled; + _userOriginalFloodTraffic = _preferences.floodTrafficEnabled; - // Enforce discovery drop if required by regional admin - if (_apiService.enforceDiscDrop && !_preferences.discDropEnabled) { - _preferences = _preferences.copyWith(discDropEnabled: true); - debugLog('[CONN] Discovery drop force-enabled by regional admin'); + if (_apiService.enforceHybrid && !_preferences.hybridModeEnabled) { + _preferences = _preferences.copyWith(hybridModeEnabled: true); + debugLog('[CONN] Hybrid mode force-enabled by regional admin'); + } + + if (_apiService.enforceDiscDrop && !_preferences.discDropEnabled) { + _preferences = _preferences.copyWith(discDropEnabled: true); + debugLog('[CONN] Discovery drop force-enabled by regional admin'); + } + + final shouldEnableFlood = !_apiService.floodDisabled; + if (_preferences.floodTrafficEnabled != shouldEnableFlood) { + _preferences = + _preferences.copyWith(floodTrafficEnabled: shouldEnableFlood); + debugLog(shouldEnableFlood + ? '[CONN] Flood traffic auto-enabled (region permits)' + : '[CONN] Flood traffic disabled by regional admin'); + } + if (_preferences.autoPingInterval < _apiService.minModeInterval) { + _preferences = + _preferences.copyWith(autoPingInterval: _apiService.minModeInterval); + debugLog( + '[CONN] Auto-ping interval bumped to ${_apiService.minModeInterval}s by regional admin'); + } + + await _configurePathHashMode(); + + _pingService = PingService( + gpsService: _gpsService, + connection: _meshCoreConnection!, + apiQueue: _apiQueueService, + wakelockService: WakelockService(), + cooldownTimer: _cooldownTimer, + manualPingCooldownTimer: _manualPingCooldownTimer, + rxWindowTimer: _rxWindowTimer, + discoveryWindowTimer: _discoveryWindowTimer, + deviceId: _deviceId, + txTracker: _txTracker, + audioService: _audioService, + disableRssiFilter: _preferences.disableRssiFilter, + hopBytes: effectiveHopBytes, + traceHopBytes: _traceHopBytes, + shouldIgnoreRepeater: (String repeaterId) { + final prefs = _preferences; + if (prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null) { + return PacketValidator.isCarpeaterIdMatch( + repeaterId, prefs.ignoreRepeaterId!); + } + return false; + }, + ); + + _pingService!.unifiedRxHandler = _unifiedRxHandler; + + _pingService!.checkExternalAntennaConfigured = () { + return _preferences.externalAntennaSet; + }; + + _pingService!.checkPowerLevelConfigured = () { + return _preferences.autoPowerSet || + _preferences.powerLevelSet || + _deviceModel != null; + }; + + _pingService!.getExternalAntenna = () => _preferences.externalAntenna; + _pingService!.getPowerLevel = () => _preferences.powerLevel; + _pingService!.checkTxAllowed = () => txAllowed; + _pingService!.getDiscDropEnabled = () => discDropEnabled; + + // Wire-tag composition (privacy-preserving TX body by default). + _pingService!.getSessionId = () => _apiService.sessionId; + _pingService!.getWireKey = () => _apiService.wireKey; + _pingService!.getNextPingCounter = () => _apiService.nextPingCounter(); + _pingService!.getBroadcastCoords = () => _preferences.broadcastCoords; + _pingService!.getPingCounter = () => _apiService.pingCounter; + _pingService!.onSessionLimitReached = + () => handleSessionError('session_limit', null); + + _pingService!.onTxPing = (ping) { + _markLiveActivityOperation(_LiveActivityOperation.sending); + _txPings.add(ping); + if (_txPings.length > _maxMapPins) _txPings.removeAt(0); + + _txLogEntries.add(TxLogEntry( + timestamp: ping.timestamp, + latitude: ping.latitude, + longitude: ping.longitude, + power: _preferences.powerLevel, + events: [], + )); + if (_txLogEntries.length > _maxLogEntries) _txLogEntries.removeAt(0); + _siriObservationRevision++; + + _notifyMapNow(); + }; + + _pingService!.onRxPing = (ping) { + _rxPings.add(ping); + if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); + + _rxLogEntries.add(RxLogEntry( + timestamp: ping.timestamp, + repeaterId: ping.repeaterId, + snr: ping.snr, + rssi: ping.rssi, + pathLength: 0, + header: 0, + latitude: ping.latitude, + longitude: ping.longitude, + )); + if (_rxLogEntries.length > _maxLogEntries) _rxLogEntries.removeAt(0); + _siriObservationRevision++; + + _updateRxOverlaySlot(ping.repeaterId, ping.snr); + _notifyMapThrottled(); + }; + + _pingService!.onStatsUpdated = (stats) { + _pingStats = stats.copyWith( + rxCount: _pingStats.rxCount, + successfulUploads: _pingStats.successfulUploads, + ); + notifyListeners(); + + if (_autoPingEnabled) { + final modeName = _autoMode == AutoMode.passive + ? 'Passive Mode' + : _autoMode == AutoMode.hybrid + ? 'Hybrid Mode' + : _autoMode == AutoMode.targeted + ? 'Trace Mode' + : 'Active Mode'; + BackgroundServiceManager.updateNotification( + mode: modeName, + txCount: _pingStats.txCount, + rxCount: _pingStats.rxCount, + queueSize: _queueSize, + ); } + }; - // Enforce minimum auto-ping interval if required by regional admin - if (_preferences.autoPingInterval < _apiService.minModeInterval) { - _preferences = _preferences.copyWith(autoPingInterval: _apiService.minModeInterval); - debugLog('[CONN] Auto-ping interval bumped to ${_apiService.minModeInterval}s by regional admin'); + _pingService!.onEchoReceived = (txPing, repeater, isNew) { + debugLog('[APP] ========== ECHO CALLBACK RECEIVED =========='); + debugLog( + '[APP] Real-time echo: ${repeater.repeaterId} (SNR: ${repeater.snr ?? 'null'}, isNew: $isNew)'); + debugLog('[APP] TxLogEntries count: ${_txLogEntries.length}'); + + if (_txLogEntries.isNotEmpty) { + final lastEntry = _txLogEntries.last; + final timeDiff = + lastEntry.timestamp.difference(txPing.timestamp).inSeconds.abs(); + if (timeDiff <= 10) { + final existingEvents = List.from(lastEntry.events); + final newEvent = RxEvent( + repeaterId: repeater.repeaterId, + snr: repeater.snr, + rssi: repeater.rssi, + ); + + if (isNew) { + existingEvents.add(newEvent); + _audioService.playReceiveSound(); + } else { + final idx = existingEvents + .indexWhere((e) => e.repeaterId == repeater.repeaterId); + if (idx >= 0) { + existingEvents[idx] = newEvent; + } + } + + final updatedEntry = TxLogEntry( + timestamp: lastEntry.timestamp, + latitude: lastEntry.latitude, + longitude: lastEntry.longitude, + power: lastEntry.power, + events: existingEvents, + multiHopEvents: lastEntry.multiHopEvents, + ); + _txLogEntries[_txLogEntries.length - 1] = updatedEntry; + _siriObservationRevision++; + debugLog( + '[APP] Updated TxLogEntry with ${existingEvents.length} direct, ' + '${lastEntry.multiHopEvents.length} multi-hop events (real-time)'); + + final directRepeaters = existingEvents + .where((event) => event.snr != null) + .map((event) => ( + repeaterId: event.repeaterId.toUpperCase(), + snr: event.snr!, + )) + .toList(growable: false); + _updateTopRepeaters(directRepeaters, OverlayPingType.tx); + _updateLiveActivityRepeaters([ + ...directRepeaters, + ...lastEntry.multiHopEvents + .where((event) => event.snr != null) + .map((event) => ( + repeaterId: event.repeaterId.toUpperCase(), + snr: event.snr!, + )), + ], OverlayPingType.tx); + + debugLog('[APP] Calling notifyListeners() to update UI'); + _notifyMapThrottled(); + debugLog('[APP] notifyListeners() completed'); + } else { + debugLog( + '[APP] Timestamp mismatch: lastEntry=${lastEntry.timestamp}, txPing=${txPing.timestamp}, diff=${timeDiff}s'); + } + } else { + debugLog('[APP] WARNING: _txLogEntries is empty, cannot update'); } + }; - // Configure multi-byte path hash mode on radio - await _configurePathHashMode(); + _pingService!.onMultiHopEchoReceived = + (txPing, repeaterId, snr, rssi, pathHops, isNew) { + debugLog( + '[APP] Multi-hop echo: $repeaterId, hops=${pathHops.length}, isNew=$isNew'); + + if (_txLogEntries.isNotEmpty) { + final lastEntry = _txLogEntries.last; + final timeDiff = + lastEntry.timestamp.difference(txPing.timestamp).inSeconds.abs(); + if (timeDiff <= 10) { + final multiHopEvents = + List.from(lastEntry.multiHopEvents); + final newEvent = MultiHopEchoEvent( + repeaterId: repeaterId, + snr: snr, + rssi: rssi, + pathHops: pathHops, + ); - // Create ping service with wakelock (create new instance per connection) - _pingService = PingService( - gpsService: _gpsService, - connection: _meshCoreConnection!, - apiQueue: _apiQueueService, - wakelockService: WakelockService(), - cooldownTimer: _cooldownTimer, - manualPingCooldownTimer: _manualPingCooldownTimer, - rxWindowTimer: _rxWindowTimer, - discoveryWindowTimer: _discoveryWindowTimer, - deviceId: _deviceId, - txTracker: _txTracker, - audioService: _audioService, - disableRssiFilter: _preferences.disableRssiFilter, - shouldIgnoreRepeater: (String repeaterId) { - // Same filter as RxLogger - check user preferences for ignored repeater ID - // Uses startsWith() for prefix matching across different hop byte sizes - final prefs = _preferences; - if (prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null) { - final ignored = prefs.ignoreRepeaterId!.toUpperCase(); - final current = repeaterId.toUpperCase(); - return current.startsWith(ignored) || ignored.startsWith(current); + if (isNew) { + multiHopEvents.add(newEvent); + _audioService.playReceiveSound(); + _pingStats = _pingStats.copyWith(rxCount: _pingStats.rxCount + 1); + } else { + final idx = + multiHopEvents.indexWhere((e) => e.repeaterId == repeaterId); + if (idx >= 0) { + multiHopEvents[idx] = newEvent; + } } - return false; - }, - ); - - // Set validation callbacks - _pingService!.checkExternalAntennaConfigured = () { - // External antenna must be explicitly set (yes or no) before pinging - return _preferences.externalAntennaSet; - }; - - _pingService!.checkPowerLevelConfigured = () { - // Power is configured if: - // - Auto-detected from device model, OR - // - Manually selected by user, OR - // - Device model is known (has default power) - return _preferences.autoPowerSet || _preferences.powerLevelSet || _deviceModel != null; - }; - - // Get external antenna value for API payloads - _pingService!.getExternalAntenna = () => _preferences.externalAntenna; - // Check if TX is allowed by API (zone capacity) - _pingService!.checkTxAllowed = () => txAllowed; + _txLogEntries[_txLogEntries.length - 1] = TxLogEntry( + timestamp: lastEntry.timestamp, + latitude: lastEntry.latitude, + longitude: lastEntry.longitude, + power: lastEntry.power, + events: lastEntry.events, + multiHopEvents: multiHopEvents, + ); + _siriObservationRevision++; + + _updateLiveActivityRepeaters([ + ...lastEntry.events + .where((event) => event.snr != null) + .map((event) => ( + repeaterId: event.repeaterId.toUpperCase(), + snr: event.snr!, + )), + ...multiHopEvents + .where((event) => event.snr != null) + .map((event) => ( + repeaterId: event.repeaterId.toUpperCase(), + snr: event.snr!, + )), + ], OverlayPingType.tx); + + _notifyMapThrottled(); + } + } + }; - // Check if discovery drop is enabled - _pingService!.getDiscDropEnabled = () => discDropEnabled; + _pingService!.onPingProgressChanged = notifyListeners; - _pingService!.onTxPing = (ping) { - _txPings.add(ping); - if (_txPings.length > _maxMapPins) _txPings.removeAt(0); + _pingService!.onAutoPingScheduled = (intervalMs, skipReason) { + _liveActivityOperation = null; + _autoPingTimer.startWithSkipReason(intervalMs, skipReason); - // Add TX log entry (power in watts from preferences) - _txLogEntries.add(TxLogEntry( - timestamp: ping.timestamp, - latitude: ping.latitude, - longitude: ping.longitude, - power: _preferences.powerLevel, // Watts (0.3, 0.6, 1.0, 2.0) - events: [], // Will be updated when RX responses come in - )); - if (_txLogEntries.length > _maxLogEntries) _txLogEntries.removeAt(0); + _updateIdleAutoStop(); + }; - notifyListeners(); - }; + _pingService!.onDiscPing = (entry) { + _markLiveActivityOperation(_LiveActivityOperation.discovering); + _addDiscLogEntry(entry); + }; - _pingService!.onRxPing = (ping) { - _rxPings.add(ping); - if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); - - // Add RX log entry - _rxLogEntries.add(RxLogEntry( - timestamp: ping.timestamp, - repeaterId: ping.repeaterId, - snr: ping.snr, - rssi: ping.rssi, - pathLength: 0, // TODO: Extract from packet metadata - header: 0, // TODO: Extract from packet metadata - latitude: ping.latitude, - longitude: ping.longitude, - )); - if (_rxLogEntries.length > _maxLogEntries) _rxLogEntries.removeAt(0); + _pingService!.onDiscNodeDiscovered = (discPing, nodeEntry, isNew) { + debugLog( + '[APP] Real-time disc node: ${nodeEntry.repeaterId}, isNew=$isNew'); + if (isNew) { + _audioService.playReceiveSound(); + } - notifyListeners(); - }; + final heardRepeaters = discPing.discoveredNodes + .map((node) => ( + repeaterId: node.repeaterId.toUpperCase(), + snr: node.localSnr, + )) + .toList(growable: false); + _updateTopRepeaters( + heardRepeaters, + OverlayPingType.disc, + identities: _discoveryIdentities(discPing.discoveredNodes), + ); + _updateLiveActivityRepeaters(heardRepeaters, OverlayPingType.disc); + _siriObservationRevision++; - _pingService!.onStatsUpdated = (stats) { - // Preserve rxCount and successfulUploads while updating TX-related stats from PingService - // PingService sends stats with rxCount=0 and successfulUploads=0 (it doesn't track these), - // so we must preserve the values that other handlers increment - _pingStats = stats.copyWith( - rxCount: _pingStats.rxCount, - successfulUploads: _pingStats.successfulUploads, - ); - notifyListeners(); + _notifyMapThrottled(); + }; - // Update background service notification with current stats - if (_autoPingEnabled) { - final modeName = _autoMode == AutoMode.passive ? 'Passive Mode' - : _autoMode == AutoMode.hybrid ? 'Hybrid Mode' : 'Active Mode'; - BackgroundServiceManager.updateNotification( - mode: modeName, - txCount: _pingStats.txCount, - rxCount: _pingStats.rxCount, - queueSize: _queueSize, - ); + _pingService!.onTxWindowComplete = (directSuccess, multiHopEchoes) { + _liveActivityOperation = null; + double? lat; + double? lon; + List? allRepeaters; + final heardRepeaters = <({String repeaterId, double snr})>[]; + + if (_txLogEntries.isNotEmpty) { + final lastTx = _txLogEntries.last; + lat = lastTx.latitude; + lon = lastTx.longitude; + + final directRepeaters = lastTx.events + .map((e) => MarkerRepeaterInfo( + repeaterId: e.repeaterId, + snr: e.snr ?? 0.0, + rssi: e.rssi ?? 0, + )) + .toList(); + + final multiHopRepeaters = multiHopEchoes + .map((e) => MarkerRepeaterInfo( + repeaterId: e.repeaterId, + snr: e.snr ?? 0.0, + rssi: e.rssi ?? 0, + pathHops: e.pathHops, + )) + .toList(); + + if (directRepeaters.isNotEmpty || multiHopRepeaters.isNotEmpty) { + allRepeaters = [...directRepeaters, ...multiHopRepeaters]; } - }; - // Handle real-time echo updates - update TxLogEntry as echoes are received - _pingService!.onEchoReceived = (txPing, repeater, isNew) { - debugLog('[APP] ========== ECHO CALLBACK RECEIVED =========='); - debugLog('[APP] Real-time echo: ${repeater.repeaterId} (SNR: ${repeater.snr ?? 'null'}, isNew: $isNew)'); - debugLog('[APP] TxLogEntries count: ${_txLogEntries.length}'); - - // Find the matching TxLogEntry and update its events - if (_txLogEntries.isNotEmpty) { - final lastEntry = _txLogEntries.last; - // Verify it's the right entry by timestamp (should be within a few seconds) - final timeDiff = lastEntry.timestamp.difference(txPing.timestamp).inSeconds.abs(); - if (timeDiff <= 10) { - // Build updated events list - final existingEvents = List.from(lastEntry.events); - final newEvent = RxEvent( - repeaterId: repeater.repeaterId, - snr: repeater.snr, - rssi: repeater.rssi, - ); + heardRepeaters.addAll(lastTx.events + .where((event) => event.snr?.isFinite ?? false) + .map((event) => ( + repeaterId: event.repeaterId.toUpperCase(), + snr: event.snr!, + ))); + heardRepeaters.addAll(multiHopEchoes + .where((event) => event.snr?.isFinite ?? false) + .map((event) => ( + repeaterId: event.repeaterId.toUpperCase(), + snr: event.snr!, + ))); + } + _updateLiveActivityRepeaters(heardRepeaters, OverlayPingType.tx); - if (isNew) { - // Add new event - existingEvents.add(newEvent); - // Play receive sound for new repeater echo - _audioService.playReceiveSound(); - } else { - // Update existing event's SNR - final idx = existingEvents.indexWhere((e) => e.repeaterId == repeater.repeaterId); - if (idx >= 0) { - existingEvents[idx] = newEvent; - } - } + final PingEventType eventType; + if (directSuccess) { + eventType = PingEventType.txSuccess; + } else if (multiHopEchoes.isNotEmpty) { + eventType = PingEventType.txMultiHopOnly; + } else { + eventType = PingEventType.txFail; + } - // Replace the entry with updated events - final updatedEntry = TxLogEntry( - timestamp: lastEntry.timestamp, - latitude: lastEntry.latitude, - longitude: lastEntry.longitude, - power: lastEntry.power, - events: existingEvents, - ); - _txLogEntries[_txLogEntries.length - 1] = updatedEntry; - debugLog('[APP] Updated TxLogEntry with ${existingEvents.length} events (real-time)'); - debugLog('[APP] Calling notifyListeners() to update UI'); - notifyListeners(); - debugLog('[APP] notifyListeners() completed'); - } else { - debugLog('[APP] Timestamp mismatch: lastEntry=${lastEntry.timestamp}, txPing=${txPing.timestamp}, diff=${timeDiff}s'); - } - } else { - debugLog('[APP] WARNING: _txLogEntries is empty, cannot update'); + recordPingEvent( + eventType, + latitude: lat, + longitude: lon, + repeaters: allRepeaters, + ); + }; + + _pingService!.onDiscoveryWindowComplete = (success) { + _liveActivityOperation = null; + double? lat; + double? lon; + List? repeaters; + final heardRepeaters = <({String repeaterId, double snr})>[]; + + if (_discLogEntries.isNotEmpty) { + final lastDisc = _discLogEntries.first; + lat = lastDisc.latitude; + lon = lastDisc.longitude; + if (lastDisc.discoveredNodes.isNotEmpty) { + repeaters = lastDisc.discoveredNodes + .map((n) => MarkerRepeaterInfo( + repeaterId: n.repeaterId, + snr: n.localSnr, + rssi: n.localRssi, + pubkeyHex: n.pubkeyHex, + )) + .toList(); } - }; + heardRepeaters.addAll(lastDisc.discoveredNodes + .where((node) => node.localSnr.isFinite) + .map((node) => ( + repeaterId: node.repeaterId.toUpperCase(), + snr: node.localSnr, + ))); + } + _updateLiveActivityRepeaters(heardRepeaters, OverlayPingType.disc); - // Wire up ping progress callback for immediate UI refresh (e.g. "Sending..." on disc) - _pingService!.onPingProgressChanged = notifyListeners; + PingEventType eventType; + if (success) { + eventType = PingEventType.discSuccess; + } else if (discDropEnabled) { + eventType = PingEventType.txFail; + } else { + eventType = PingEventType.discFail; + } - // Wire up auto ping scheduled callback for countdown display - _pingService!.onAutoPingScheduled = (intervalMs, skipReason) { - _autoPingTimer.startWithSkipReason(intervalMs, skipReason); - }; + recordPingEvent( + eventType, + latitude: lat, + longitude: lon, + repeaters: repeaters, + ); + }; - // Wire up discovery ping callback - fires immediately (like onTxPing) - _pingService!.onDiscPing = (entry) { - _addDiscLogEntry(entry); - }; + _pingService!.onTracePing = (entry) { + _markLiveActivityOperation(_LiveActivityOperation.tracing); + _addTraceLogEntry(entry); + }; - // Wire up real-time disc node discovery callback (like onEchoReceived) - _pingService!.onDiscNodeDiscovered = (discPing, nodeEntry, isNew) { - debugLog('[APP] Real-time disc node: ${nodeEntry.repeaterId}, isNew=$isNew'); - if (isNew) { - _audioService.playReceiveSound(); + _pingService!.onTraceWindowComplete = (result) { + _liveActivityOperation = null; + double? lat; + double? lon; + List? repeaters; + + if (_traceLogEntries.isNotEmpty) { + final lastTrace = _traceLogEntries.first; + lat = lastTrace.latitude; + lon = lastTrace.longitude; + if (result != null && result.success) { + repeaters = [ + MarkerRepeaterInfo( + repeaterId: result.targetRepeaterId, + snr: result.localSnr, + rssi: result.localRssi, + ) + ]; + _traceLogEntries[0] = TraceLogEntry( + timestamp: lastTrace.timestamp, + latitude: lastTrace.latitude, + longitude: lastTrace.longitude, + targetRepeaterId: lastTrace.targetRepeaterId, + noiseFloor: lastTrace.noiseFloor, + localSnr: result.localSnr, + remoteSnr: result.remoteSnr, + localRssi: result.localRssi, + success: true, + ); + _siriObservationRevision++; + _notifyMapNow(); } - notifyListeners(); - }; + } - // Wire up TX window complete callback for noise floor graph - _pingService!.onTxWindowComplete = (success) { - // Get location and repeater info from the last TX log entry - double? lat; - double? lon; - List? repeaters; - - if (_txLogEntries.isNotEmpty) { - final lastTx = _txLogEntries.last; - lat = lastTx.latitude; - lon = lastTx.longitude; - if (lastTx.events.isNotEmpty) { - repeaters = lastTx.events.map((e) => MarkerRepeaterInfo( - repeaterId: e.repeaterId, - snr: e.snr ?? 0.0, - rssi: e.rssi ?? 0, - )).toList(); - } - } + final traceSnr = result?.localSnr; + _updateLiveActivityRepeaters( + result != null && result.success && traceSnr != null + ? [(repeaterId: result.targetRepeaterId, snr: traceSnr)] + : const [], + OverlayPingType.trace, + ); - recordPingEvent( - success ? PingEventType.txSuccess : PingEventType.txFail, - latitude: lat, - longitude: lon, - repeaters: repeaters, - ); - }; + recordPingEvent( + result != null && result.success + ? PingEventType.traceSuccess + : PingEventType.traceFail, + latitude: lat, + longitude: lon, + repeaters: repeaters, + ); + }; - // Wire up discovery window complete callback for noise floor graph - _pingService!.onDiscoveryWindowComplete = (success) { - // Get location and node info from the most recent discovery log entry - // Note: _discLogEntries uses insert(0,...) so .first is newest - double? lat; - double? lon; - List? repeaters; - - if (_discLogEntries.isNotEmpty) { - final lastDisc = _discLogEntries.first; - lat = lastDisc.latitude; - lon = lastDisc.longitude; - if (lastDisc.discoveredNodes.isNotEmpty) { - repeaters = lastDisc.discoveredNodes.map((n) => MarkerRepeaterInfo( - repeaterId: n.repeaterId, - snr: n.localSnr, - rssi: n.localRssi, - pubkeyHex: n.pubkeyHex, - )).toList(); - } - } + _pingService!.onDiscCarpeaterDrop = (String repeaterId, String reason) { + debugLog( + '[APP] Discovery carpeater drop: repeater=$repeaterId, reason=$reason'); + logError('Discovery Dropped\nPossible carpeater: $repeaterId\n$reason', + severity: ErrorSeverity.warning, autoSwitch: false); + }; - PingEventType eventType; - if (success) { - eventType = PingEventType.discSuccess; - } else if (discDropEnabled) { - eventType = PingEventType.txFail; - } else { - eventType = PingEventType.discFail; - } + _pingService!.onPendingDisableComplete = () async { + debugLog('[APP] Pending disable completed, cleaning up'); - recordPingEvent( - eventType, - latitude: lat, - longitude: lon, - repeaters: repeaters, - ); - }; + _pingService!.stopEchoTracking(); + _rxLogger?.stopWardriving(trigger: 'pending_disable'); - // Wire up discovery carpeater drop callback (for DiscTracker RSSI failsafe) - _pingService!.onDiscCarpeaterDrop = (String repeaterId, String reason) { - debugLog('[APP] Discovery carpeater drop: repeater=$repeaterId, reason=$reason'); - logError('Discovery Dropped\nPossible carpeater: $repeaterId\n$reason', - severity: ErrorSeverity.warning, autoSwitch: false); - }; + await BackgroundServiceManager.stopService(); - // Wire up pending disable complete callback - // Called when user disables Active Mode during sending/listening and the RX window ends - _pingService!.onPendingDisableComplete = () async { - debugLog('[APP] Pending disable completed, cleaning up'); + _autoPingTimer.stop(); + _rxWindowTimer.stop(); + + if (_preferences.offlineMode) { + await _saveOfflineSession(); + } - // Stop TX echo tracking - _pingService!.stopEchoTracking(); - // Stop RX wardriving (flushes batches) - _rxLogger?.stopWardriving(trigger: 'pending_disable'); + await _endNoiseFloorSession(); + _apiService.disableHeartbeat(); - // Stop background service - await BackgroundServiceManager.stopService(); + _autoPingEnabled = false; + _resetIdleAutoStop(); + _finishLiveActivitySession(); - // Stop countdown timers - _autoPingTimer.stop(); - _rxWindowTimer.stop(); + debugLog('[APP] Pending disable cleanup complete, cooldown running'); + notifyListeners(); + }; - // Save offline session if offline mode is enabled - if (_preferences.offlineMode) { - await _saveOfflineSession(); + await _saveRememberedDevice(device, + transportType: _selectedTransport, + tcpHost: tcpHost, + tcpPort: tcpPort, + serialPortPath: serialPortPath); + + final selfInfoName = _meshCoreConnection?.selfInfo?.name; + if (selfInfoName != null && selfInfoName.isNotEmpty) { + _displayDeviceName = _isAnonymousRenamed ? 'Anonymous' : selfInfoName; + debugLog('[APP] Display name set: "$_displayDeviceName"'); + + String? realName; + if (_isAnonymousRenamed) { + realName = _originalDeviceName ?? selfInfoName; + } else if (selfInfoName == 'Anonymous' && _devicePublicKey != null) { + realName = _deviceRealNames[_devicePublicKey!] ?? selfInfoName; + } else { + realName = selfInfoName; + } + if (_rememberedDevice != null && _rememberedDevice!.id == device.id) { + final updatedName = 'MeshCore-$realName'; + if (_rememberedDevice!.name != updatedName) { + await _saveRememberedDevice( + DiscoveredDevice(id: device.id, name: updatedName), + transportType: _selectedTransport, + tcpHost: tcpHost, + tcpPort: tcpPort, + serialPortPath: serialPortPath); + debugLog( + '[APP] Updated remembered device name from SelfInfo: $updatedName'); } + } + } - // End noise floor session - await _endNoiseFloorSession(); + final resolvedName = + _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; + if (resolvedName != null && + _deviceAntennaPreferences.containsKey(resolvedName)) { + final savedAntenna = _deviceAntennaPreferences[resolvedName]!; + _preferences = _preferences.copyWith( + externalAntenna: savedAntenna, + externalAntennaSet: true, + ); + _antennaRestoredFromDevice = true; + _savePreferences(); + debugLog( + '[APP] Restored antenna preference for "$resolvedName": ${savedAntenna ? "external" : "device"}'); + notifyListeners(); + } - // Disable heartbeat - _apiService.disableHeartbeat(); + if (resolvedName != null && + _devicePowerOverrides.containsKey(resolvedName)) { + final saved = _devicePowerOverrides[resolvedName]!; + _preferences = _preferences.copyWith( + powerLevel: (saved['powerLevel'] as num).toDouble(), + txPower: (saved['txPower'] as num).toInt(), + autoPowerSet: false, + powerLevelSet: true, + ); + _powerRestoredFromDevice = true; + _savePreferences(); + debugLog( + '[APP] Restored power override for "$resolvedName": ${saved['powerLevel']}W'); + notifyListeners(); + } - // Update local state - _autoPingEnabled = false; + if (hasApiSession) { + if (txAllowed && rxAllowed) { + debugLog('[CONN] Connected with full access (TX + RX allowed)'); + } else if (rxAllowed) { + debugLog( + '[CONN] Connected with RX-only access (TX not allowed, zone at TX capacity)'); + } else { + debugLog('[CONN] Connected with limited access'); + } - debugLog('[APP] Pending disable cleanup complete, cooldown running'); - notifyListeners(); - }; + _sessionZoneCode = zoneCode; - // Save this device for quick reconnection (mobile only) - await _saveRememberedDevice(device); - - // Update display name from SelfInfo (reflects user's chosen name) - // BLE advertisement name may be cached/stale after device rename - final selfInfoName = _meshCoreConnection?.selfInfo?.name; - if (selfInfoName != null && selfInfoName.isNotEmpty) { - // Keep "Anonymous" display name if anonymous mode is active - _displayDeviceName = _isAnonymousRenamed ? 'Anonymous' : selfInfoName; - debugLog('[APP] Display name set: "$_displayDeviceName"'); - - // Update remembered device with real name (not "Anonymous") - // BLE advertisement name may be stale after device rename - final realName = _isAnonymousRenamed ? (_originalDeviceName ?? selfInfoName) : selfInfoName; - if (_rememberedDevice != null && _rememberedDevice!.id == device.id) { - final updatedName = 'MeshCore-$realName'; - if (_rememberedDevice!.name != updatedName) { - await _saveRememberedDevice(DiscoveredDevice(id: device.id, name: updatedName)); - debugLog('[APP] Updated remembered device name from SelfInfo: $updatedName'); - } - } + if (!_preferences.offlineMode) { + _startZoneRefreshTimer(); } - // Restore per-device antenna preference if previously saved - // Use original name for keying, not "Anonymous" - final resolvedName = _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; - if (resolvedName != null && _deviceAntennaPreferences.containsKey(resolvedName)) { - final savedAntenna = _deviceAntennaPreferences[resolvedName]!; - _preferences = _preferences.copyWith( - externalAntenna: savedAntenna, - externalAntennaSet: true, + if (!_preferences.offlineMode && _apiService.hasSession) { + _apiService.enableHeartbeat( + gpsProvider: () { + final pos = _gpsService.lastPosition; + if (pos == null) return null; + return (lat: pos.latitude, lon: pos.longitude); + }, ); - _antennaRestoredFromDevice = true; - _savePreferences(); - debugLog('[APP] Restored antenna preference for "$resolvedName": ${savedAntenna ? "external" : "device"}'); - notifyListeners(); + debugLog('[HEARTBEAT] Enabled on connection'); } - // Log connection status based on TX/RX permissions - if (hasApiSession) { - if (txAllowed && rxAllowed) { - debugLog('[CONN] Connected with full access (TX + RX allowed)'); - } else if (rxAllowed) { - debugLog('[CONN] Connected with RX-only access (TX not allowed, zone at TX capacity)'); - } else { - debugLog('[CONN] Connected with limited access'); - } - } else { - // No API session - offline mode or auth skipped - debugLog('[CONN] Connected without API session (offline mode)'); - } + _startIdleDisconnectTimer(); + } else { + debugLog('[CONN] Connected without API session (offline mode)'); + } - // Log ping validation status after connection - final validation = pingValidation; - if (validation != PingValidation.valid) { - debugLog('[CONN] Ping validation after connect: $validation'); - } + final validation = pingValidation; + if (validation != PingValidation.valid) { + debugLog('[CONN] Ping validation after connect: $validation'); + } + // MyMeshMapper account link offer. Strictly non-fatal: it must never + // block, slow, or fail a connection, so it is fire-and-forget inside its + // own guard. + try { + unawaited(_maybeOfferDeviceLink()); } catch (e) { - debugError('[APP] Connection failed: $e'); - - // Ensure channel is cleaned up if it was created during connection - // Must happen BEFORE BLE disconnect while connection is still alive - try { - await _meshCoreConnection?.deleteWardrivingChannelEarly(); - } catch (channelError) { - debugError('[APP] Cleanup channel delete failed: $channelError'); - } + debugWarn('[ACCOUNT] Link offer failed to start: $e'); + } - // Ensure BLE is disconnected on any connection failure - // (connection.dart should have done this, but be defensive) + // Heal a stale cache: a portal-side unlink or a revoked token is discovered + // within the hour instead of never. Non-forced, so the service's own 1/hour + // throttle bounds the writes this makes to the site-wide auth DB, and it + // returns immediately when signed out. + if (!kIsWeb) { try { - if (_meshCoreConnection != null) { - await _meshCoreConnection!.disconnect(); - } - } catch (disconnectError) { - debugError('[APP] Cleanup disconnect failed: $disconnectError'); - } - - // Parse auth failure errors for clean display - final errorStr = e.toString(); - if (errorStr.contains('AUTH_FAILED:')) { - // Format: "Exception: AUTH_FAILED:reason:message" - _isAuthError = true; - final parts = errorStr.split('AUTH_FAILED:'); - if (parts.length > 1) { - final errorParts = parts[1].split(':'); - final reason = errorParts.isNotEmpty ? errorParts[0] : 'unknown'; - final serverMessage = errorParts.length > 1 ? errorParts.sublist(1).join(':') : null; - _connectionError = _getErrorMessage(reason, serverMessage); - } else { - _connectionError = 'Authentication failed'; - } - } else { - _isAuthError = false; - // Provide clean user-facing messages for common BLE errors - if (errorStr.contains('timeout') || errorStr.contains('Timeout') || errorStr.contains('timed out')) { - _connectionError = 'Bluetooth connection scan timed out'; - } else { - _connectionError = errorStr.replaceFirst('Exception: ', ''); - } + unawaited(_portalAccountService.refreshMe()); + } catch (e) { + debugWarn('[ACCOUNT] Account refresh failed to start: $e'); } - _connectionStep = ConnectionStep.error; - notifyListeners(); } } @@ -1546,8 +5244,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _txTracker!.disableRssiFilter = _preferences.disableRssiFilter; // Set CARpeater prefix for pass-through (replaces shouldIgnoreRepeater) - _txTracker!.carpeaterPrefix = _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; - debugLog('[APP] TxTracker.carpeaterPrefix set to ${_txTracker!.carpeaterPrefix ?? 'null'}'); + _txTracker!.carpeaterPrefix = + _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; + debugLog( + '[APP] TxTracker.carpeaterPrefix set to ${_txTracker!.carpeaterPrefix ?? 'null'}'); // Log TX carpeater drops to error log (without navigating to error tab) _txTracker!.onCarpeaterDrop = (String repeaterId, String reason) { @@ -1560,16 +5260,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Create RX logger (stored for use when enabling Passive Mode) _rxLogger = RxLogger( // CARpeater prefix for pass-through (replaces shouldIgnoreRepeater) - carpeaterPrefix: _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null, + carpeaterPrefix: + _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null, // Immediate observation callback - fires when packet is first validated // Creates pin IMMEDIATELY for NEW repeaters (first time in current batch) onObservation: (observation) { try { - debugLog('[APP] Immediate RX observation: repeater=${observation.repeaterId}, ' + debugLog( + '[APP] Immediate RX observation: repeater=${observation.repeaterId}, ' 'snr=${observation.snr ?? 'null'}, location=${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)}'); // Log current batch tracking state for debugging - debugLog('[APP] Current batch tracking: ${_currentBatchRepeaters.length} repeaters: $_currentBatchRepeaters'); + debugLog( + '[APP] Current batch tracking: ${_currentBatchRepeaters.length} repeaters: $_currentBatchRepeaters'); // Check if repeater already has a pin in CURRENT BATCH (not all-time) // This allows new pins after batch flushes (25m movement) @@ -1583,6 +5286,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { timestamp: DateTime.now(), snr: observation.snr ?? 0.0, rssi: observation.rssi ?? 0, + pathHops: observation.displayHops, ); _rxPings.add(rxPing); if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); @@ -1591,9 +5295,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Increment RX count immediately when pin is created (not on batch flush) _pingStats = _pingStats.copyWith(rxCount: _pingStats.rxCount + 1); - debugLog('[APP] Created IMMEDIATE RX pin for repeater: ${observation.repeaterId} ' + debugLog( + '[APP] Created IMMEDIATE RX pin for repeater: ${observation.repeaterId} ' 'at ${observation.lat.toStringAsFixed(5)},${observation.lon.toStringAsFixed(5)} ' '(batch tracking: ${_currentBatchRepeaters.length} repeaters, rxCount: ${_pingStats.rxCount})'); + // Update RX overlay slot immediately + if (observation.snr != null) { + _updateRxOverlaySlot(repeaterKey, observation.snr!); + } // Play receive sound for new RX observation _audioService.playReceiveSound(); // Record RX event for noise floor graph with location and repeater info @@ -1609,9 +5318,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ), ], ); - notifyListeners(); + _notifyMapThrottled(); } else { - debugLog('[APP] Repeater ${observation.repeaterId} already has pin in current batch, SNR will update on flush if better'); + debugLog( + '[APP] Repeater ${observation.repeaterId} already has pin in current batch, SNR will update on flush if better'); } } catch (e, stackTrace) { debugError('[APP] Error in immediate observation callback: $e'); @@ -1624,7 +5334,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { onRxEntry: (entry) async { try { debugLog('[APP] ========== BATCH FLUSH CALLBACK =========='); - debugLog('[APP] Finalized RX entry (best SNR): repeater=${entry.repeaterId}, ' + debugLog( + '[APP] Finalized RX entry (best SNR): repeater=${entry.repeaterId}, ' 'snr=${entry.snr ?? 'null'}, location=${entry.lat.toStringAsFixed(5)},${entry.lon.toStringAsFixed(5)}'); final repeaterKey = entry.repeaterId.toUpperCase(); @@ -1643,20 +5354,25 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Update the pin's SNR to the best from this batch final existingPin = _rxPings[lastPinIndex]; // Only update if new SNR is non-null and better (null never replaces non-null) - final shouldUpdateSnr = entry.snr != null && entry.snr! > existingPin.snr; + final shouldUpdateSnr = + entry.snr != null && entry.snr! > existingPin.snr; if (shouldUpdateSnr) { _rxPings[lastPinIndex] = RxPing( - latitude: existingPin.latitude, // KEEP batch start location + latitude: existingPin.latitude, // KEEP batch start location longitude: existingPin.longitude, // KEEP batch start location repeaterId: entry.repeaterId, timestamp: entry.timestamp, - snr: entry.snr ?? existingPin.snr, // UPDATE to best SNR from batch + snr: entry.snr ?? + existingPin.snr, // UPDATE to best SNR from batch rssi: entry.rssi ?? existingPin.rssi, + pathHops: existingPin.pathHops, ); - debugLog('[APP] Updated RX pin SNR for repeater=${entry.repeaterId}: ' + debugLog( + '[APP] Updated RX pin SNR for repeater=${entry.repeaterId}: ' '${existingPin.snr.toStringAsFixed(2)} -> ${entry.snr?.toStringAsFixed(2) ?? 'null'}'); } else { - debugLog('[APP] RX pin SNR unchanged for repeater=${entry.repeaterId}: ' + debugLog( + '[APP] RX pin SNR unchanged for repeater=${entry.repeaterId}: ' 'batch best ${entry.snr?.toStringAsFixed(2) ?? 'null'} <= pin ${existingPin.snr.toStringAsFixed(2)}'); } } else { @@ -1668,10 +5384,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { timestamp: entry.timestamp, snr: entry.snr ?? 0.0, rssi: entry.rssi ?? 0, + pathHops: entry.displayHops, ); _rxPings.add(newRxPing); if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); - debugLog('[APP] Created FALLBACK RX pin for repeater=${entry.repeaterId} ' + debugLog( + '[APP] Created FALLBACK RX pin for repeater=${entry.repeaterId} ' 'at ${entry.lat.toStringAsFixed(5)},${entry.lon.toStringAsFixed(5)}'); } @@ -1691,14 +5409,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { header: entry.header, latitude: entry.lat, longitude: entry.lon, + pathHops: entry.displayHops, ); // Add to RX log entries _rxLogEntries.add(rxLogEntry); if (_rxLogEntries.length > _maxLogEntries) _rxLogEntries.removeAt(0); + _siriObservationRevision++; debugLog('[APP] Added RX log entry: repeater=${entry.repeaterId}, ' 'snr=${entry.snr ?? 'null'}, pathLen=${entry.pathLength}'); + // Update RX overlay slot with this RX observation + if (entry.snr != null) { + _updateRxOverlaySlot(entry.repeaterId, entry.snr!); + } + // Note: RX count is incremented in onObservation when pin is created (immediate feedback) // Enqueue to API with formatted heard_repeats string @@ -1714,10 +5439,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { repeaterId: entry.repeaterId, externalAntenna: _preferences.externalAntenna, noiseFloor: _meshCoreConnection?.lastNoiseFloor, + power: _preferences.powerLevel, ); - // Update UI - notifyListeners(); + // Update UI (throttled — dense mesh RX must not churn the map) + _notifyMapThrottled(); } catch (e, stackTrace) { debugError('[APP] Error in finalized RX entry callback: $e'); debugError('[APP] Stack trace: $stackTrace'); @@ -1736,8 +5462,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { logError('RX Dropped\nPossible carpeater: $repeaterId\n$reason', severity: ErrorSeverity.warning, autoSwitch: false); }, + + // Explain RX silence when there's no GPS fix (#340): without this the RX + // counter quietly stops and the app appears "offline despite Internet". + onNoGpsDrop: () { + debugLog('[APP] RX not logged: no GPS fix available'); + logError( + 'RX not logged\nNo GPS fix — waiting for location.\n' + 'Detection resumes automatically once GPS is restored.', + severity: ErrorSeverity.warning, + autoSwitch: false); + }, ); - + // Create packet validator with ALL allowed channels (#wardriving, #testing, #ottawa, Public) final allowedChannelsData = ChannelService.getAllowedChannelsForValidator(); final allowedChannels = {}; @@ -1748,7 +5485,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { hash: entry.value.hash, ); } - debugLog('[APP] PacketValidator configured with ${allowedChannels.length} channels: ' + debugLog( + '[APP] PacketValidator configured with ${allowedChannels.length} channels: ' '${allowedChannelsData.values.map((c) => c.channelName).join(', ')}'); final validator = PacketValidator( allowedChannels: allowedChannels, @@ -1761,15 +5499,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { rxLogger: _rxLogger!, validator: validator, ); - + // Subscribe to LogRxData stream - _logRxDataSubscription = _meshCoreConnection!.logRxDataStream.listen((data) { + _logRxDataSubscription = + _meshCoreConnection!.logRxDataStream.listen((data) { _unifiedRxHandler!.handlePacket(data.raw, data.snr, data.rssi); }); - + // Start listening _unifiedRxHandler!.startListening(); - + debugLog('[APP] Unified RX handler created and listening'); } @@ -1781,28 +5520,39 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final deviceInfo = _meshCoreConnection?.deviceInfo; if (deviceInfo == null) return; - // Store the device's current mode (from DeviceInfo response) + // Capture what the radio is CURRENTLY doing before resetting to firmware + // default — during zone transfer this reflects the previous zone's mode + final currentRuntimeHopBytes = _hopBytes; + + // Store the device's original firmware mode (from DeviceInfo response) _originalPathHashMode = deviceInfo.pathHashMode; - // Sync runtime hopBytes from device's current mode + // Sync runtime hopBytes from device's firmware mode + final deviceMode = + _originalPathHashMode ?? 0; // null = old firmware, treat as 0 (1-byte) + final deviceHopBytes = deviceMode + 1; if (_originalPathHashMode != null) { - final deviceHopBytes = _originalPathHashMode! + 1; _hopBytes = deviceHopBytes; - debugLog('[PATH] Read device path mode: $deviceHopBytes-byte'); + _traceHopBytes = deviceHopBytes == 3 ? 4 : deviceHopBytes; + _pingService?.traceHopBytes = _traceHopBytes; + debugLog( + '[PATH] Read device path mode: $deviceHopBytes-byte (trace: $_traceHopBytes-byte)'); } else { _hopBytes = 1; + _traceHopBytes = 1; } final effective = effectiveHopBytes; - final deviceMode = _originalPathHashMode ?? 0; // null = old firmware, treat as 0 (1-byte) - final deviceHopBytes = deviceMode + 1; - if (effective != deviceHopBytes && _originalPathHashMode != null) { + if (effective != currentRuntimeHopBytes && _originalPathHashMode != null) { // Need to change the radio's path hash mode try { await _meshCoreConnection!.setPathHashMode(effective - 1); - _hopBytes = effective; // Update runtime state to reflect new mode - debugLog('[PATH] Set path hash mode: device was $deviceHopBytes-byte, now $effective-byte'); + _hopBytes = effective; + _traceHopBytes = effective == 3 ? 4 : effective; + _pingService?.traceHopBytes = _traceHopBytes; + debugLog( + '[PATH] Set path hash mode: radio was $currentRuntimeHopBytes-byte, now $effective-byte (trace: $_traceHopBytes-byte)'); // Show warning popup if changing from 1-byte to multi-byte if (deviceMode == 0 && effective > 1) { @@ -1810,20 +5560,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ? 'set by your regional admin' : 'set in your app preferences'; _pendingPathHashWarning = (hopBytes: effective, reason: reason); - notifyListeners(); // Trigger UI to show warning + notifyListeners(); } } catch (e) { debugError('[PATH] Failed to set path hash mode: $e'); } } else if (_originalPathHashMode == null && effective > 1) { // Old firmware doesn't support multi-byte paths — warn user, fall back to 1-byte - debugWarn('[PATH] Device firmware does not report path_hash_mode, cannot set $effective-byte paths'); + debugWarn( + '[PATH] Device firmware does not report path_hash_mode, cannot set $effective-byte paths'); if (enforceHopBytes) { - _pendingPathHashWarning = (hopBytes: effective, reason: 'firmware_unsupported'); + _pendingPathHashWarning = + (hopBytes: effective, reason: 'firmware_unsupported'); notifyListeners(); } } else { - debugLog('[PATH] Path hash mode OK: device=$deviceHopBytes-byte, effective=$effective-byte'); + debugLog( + '[PATH] Path hash mode OK: radio=$currentRuntimeHopBytes-byte, effective=$effective-byte'); } } @@ -1833,7 +5586,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_originalPathHashMode == null) return; if (_userChangedPathMode) { - debugLog('[PATH] User manually changed path mode, not restoring on disconnect'); + debugLog( + '[PATH] User manually changed path mode, not restoring on disconnect'); _originalPathHashMode = null; _userChangedPathMode = false; return; @@ -1846,12 +5600,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_hopBytes != originalHopBytes) { try { await _meshCoreConnection?.setPathHashMode(originalMode); - debugLog('[PATH] Restored path hash mode to original: $originalHopBytes-byte'); + debugLog( + '[PATH] Restored path hash mode to original: $originalHopBytes-byte'); } catch (e) { debugError('[PATH] Failed to restore path hash mode: $e'); } } else { - debugLog('[PATH] Path mode unchanged from original ($originalHopBytes-byte), no restore needed'); + debugLog( + '[PATH] Path mode unchanged from original ($originalHopBytes-byte), no restore needed'); } _originalPathHashMode = null; _userChangedPathMode = false; @@ -1862,7 +5618,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_originalPathHashMode == null) { // Old firmware — can't send command, show warning debugWarn('[PATH] Cannot change path mode: firmware does not support it'); - _pendingPathHashWarning = (hopBytes: newHopBytes, reason: 'firmware_unsupported'); + _pendingPathHashWarning = + (hopBytes: newHopBytes, reason: 'firmware_unsupported'); _hopBytes = 1; // Force back to 1 notifyListeners(); return; @@ -1870,9 +5627,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _hopBytes = newHopBytes; _userChangedPathMode = true; + _pingService?.hopBytes = newHopBytes; + // Auto-map trace bytes when TX bytes change (3→4, others stay same) + final oldTraceHopBytes = _traceHopBytes; + _traceHopBytes = newHopBytes == 3 ? 4 : newHopBytes; + _pingService?.traceHopBytes = _traceHopBytes; + // Clear target repeater if trace bytes changed — old hex ID has wrong byte length + if (_traceHopBytes != oldTraceHopBytes) { + _targetRepeaterId = null; + } final mode = newHopBytes - 1; // Convert 1/2/3 → mode 0/1/2 _meshCoreConnection?.setPathHashMode(mode); - debugLog('[PATH] User changed path mode to $newHopBytes-byte (sent to radio)'); + debugLog( + '[PATH] User changed path mode to $newHopBytes-byte (trace: $_traceHopBytes-byte, sent to radio)'); notifyListeners(); } @@ -1889,29 +5656,79 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + /// Set trace hop bytes (called from settings UI). Valid values: 1, 2, 4. + void setTraceHopBytes(int value) { + if (value != 1 && value != 2 && value != 4) return; + if (value == _traceHopBytes) return; + _traceHopBytes = value; + _pingService?.traceHopBytes = value; + // Clear target repeater — old hex ID has wrong byte length + _targetRepeaterId = null; + debugLog('[TRACE] User changed trace bytes to $value'); + notifyListeners(); + } + /// Pending path hash warning data (for UI to show dialog) ({int hopBytes, String reason})? _pendingPathHashWarning; - ({int hopBytes, String reason})? get pendingPathHashWarning => _pendingPathHashWarning; + ({int hopBytes, String reason})? get pendingPathHashWarning => + _pendingPathHashWarning; /// Clear the pending warning after UI has shown it void clearPathHashWarning() { _pendingPathHashWarning = null; } - Future _fullDisconnectCleanup() async { + /// In-flight full-cleanup future so a connection workflow re-run (#500) + /// can wait for the disconnect-event cleanup to settle before reconnecting. + /// Cleared on completion so a long-finished cleanup is never mistaken for + /// one belonging to the current failure. + Future? _fullCleanupInFlight; + + Future _fullDisconnectCleanup() { + late final Future cleanup; + cleanup = _fullDisconnectCleanupImpl().whenComplete(() { + if (identical(_fullCleanupInFlight, cleanup)) { + _fullCleanupInFlight = null; + } + }); + _fullCleanupInFlight = cleanup; + return cleanup; + } + + Future _fullDisconnectCleanupImpl() async { + _finishLiveActivitySession(); + // Guard against double cleanup (e.g., reconnect timeout + BLE disconnect event) + if (_connectionStep == ConnectionStep.disconnected) { + debugLog('[CONN] Already disconnected, skipping duplicate cleanup'); + return; + } + _cancelPendingAutoPingRestore(); + _isConnecting = false; _connectionStep = ConnectionStep.disconnected; - // Stop heartbeat immediately on BLE disconnect + // Cancel any active zone grace period + _cancelZoneGraceTimers(); + _isInZoneGracePeriod = false; + _zoneGraceSecondsRemaining = 0; + _autoPingWasEnabledBeforeGrace = false; + _apiService.disableHeartbeat(); - debugLog('[CONN] Heartbeat disabled due to BLE disconnect'); + debugLog('[CONN] Heartbeat disabled due to disconnect'); + + // Stop zone refresh timer + _stopZoneRefreshTimer(); // Stop auto-ping timers _autoPingTimer.stop(); _rxWindowTimer.stop(); _cooldownTimer.stop(); if (_autoPingEnabled) { + if (!_userRequestedDisconnect) { + _playDisconnectAlert(); + } _autoPingEnabled = false; - debugLog('[AUTO] Auto-ping disabled due to BLE disconnect'); + _resetIdleAutoStop(); + debugLog('[AUTO] Auto-ping disabled due to disconnect'); } // End noise floor session on BLE disconnect @@ -1935,7 +5752,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Release API session (best effort - don't block on failure) if (_devicePublicKey != null && _apiService.hasSession) { - debugLog('[CONN] Releasing API session due to BLE disconnect'); + debugLog('[CONN] Releasing API session due to disconnect'); try { await _apiService.requestAuth( reason: 'disconnect', @@ -1947,21 +5764,39 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } - // Reset anonymous mode state (BLE already gone, can't restore name) _isAnonymousRenamed = false; _originalDeviceName = null; - // Existing cleanup + _clearOverlayState(); + _meshCoreConnection?.dispose(); _meshCoreConnection = null; _pingService?.dispose(); _pingService = null; + + // Clean up non-BLE transport + _transportConnectionSubscription?.cancel(); + _transportConnectionSubscription = null; + if (_activeTransport != null && _activeTransport != _bluetoothService) { + _activeTransport!.dispose(); + } + _activeTransport = null; } - /// Start auto-reconnect after unexpected BLE disconnect + /// Start auto-reconnect after unexpected transport disconnect Future _startAutoReconnect() async { + // Defensive: cancel zone grace period if active + if (_isInZoneGracePeriod) { + _cancelZoneGraceTimers(); + _isInZoneGracePeriod = false; + _zoneGraceSecondsRemaining = 0; + _autoPingWasEnabledBeforeGrace = false; + } + _cancelPendingAutoPingRestore(); + _cancelIdleDisconnectTimer(); _isAutoReconnecting = true; _reconnectAttempt = 0; + _lastReconnectWasBondError = false; _connectionStep = ConnectionStep.reconnecting; // Remember auto-ping state before cleanup @@ -1973,6 +5808,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxWindowTimer.stop(); _cooldownTimer.stop(); _autoPingEnabled = false; + _resetIdleAutoStop(); // Stop heartbeat _apiService.disableHeartbeat(); @@ -2003,7 +5839,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _pingService = null; // Do NOT release API session or clear API queue - debugLog('[CONN] Auto-reconnect: preserved API session, cleaned up BLE objects'); + debugLog( + '[CONN] Auto-reconnect: preserved API session, cleaned up BLE objects'); notifyListeners(); @@ -2020,37 +5857,64 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Attempt a single reconnection void _attemptReconnect() { if (_reconnectAttempt >= _maxReconnectAttempts) { - debugLog('[CONN] Auto-reconnect: max attempts reached ($_maxReconnectAttempts)'); + debugLog( + '[CONN] Auto-reconnect: max attempts reached ($_maxReconnectAttempts)'); _abandonAutoReconnect(); return; } _reconnectAttempt++; - debugLog('[CONN] Auto-reconnect attempt $_reconnectAttempt of $_maxReconnectAttempts'); + debugLog( + '[CONN] Auto-reconnect attempt $_reconnectAttempt of $_maxReconnectAttempts'); notifyListeners(); + // Use longer delay after bond errors to give iOS time to clear stale keys + final delay = _lastReconnectWasBondError + ? _reconnectDelayAfterBondError + : _reconnectDelay; + // Delay before attempting reconnection - _reconnectTimer = Timer(_reconnectDelay, () async { + _reconnectTimer = Timer(delay, () async { if (!_isAutoReconnecting) return; // Cancelled while waiting try { - debugLog('[CONN] Auto-reconnect: calling reconnectToRememberedDevice()'); + debugLog( + '[CONN] Auto-reconnect: calling reconnectToRememberedDevice()'); await reconnectToRememberedDevice(); + // Timeout or cancel fired while connection was in-flight. + // Disconnect the orphaned connection — abandon already cleaned up state. + if (!_isAutoReconnecting) { + if (_connectionStep == ConnectionStep.connected) { + debugLog( + '[CONN] Auto-reconnect completed after timeout — disconnecting orphaned connection'); + disconnect(); + } + return; + } + // If we get here and connection step is 'connected', success! if (_connectionStep == ConnectionStep.connected) { - debugLog('[CONN] Auto-reconnect succeeded on attempt $_reconnectAttempt'); + debugLog( + '[CONN] Auto-reconnect succeeded on attempt $_reconnectAttempt'); + _lastReconnectWasBondError = false; _onReconnectSuccess(); } else if (_isAutoReconnecting) { // Connection failed but didn't throw - try again - debugLog('[CONN] Auto-reconnect: connection did not complete, retrying...'); + debugLog( + '[CONN] Auto-reconnect: connection did not complete, retrying...'); _connectionStep = ConnectionStep.reconnecting; notifyListeners(); _attemptReconnect(); } } catch (e) { - debugError('[CONN] Auto-reconnect attempt $_reconnectAttempt failed: $e'); + debugError( + '[CONN] Auto-reconnect attempt $_reconnectAttempt failed: $e'); if (_isAutoReconnecting) { + // Check for iOS apple-code 14 (Peer removed pairing information) + // The MeshCore device cleared its bond keys — clear iOS stale bond before retrying + await _handleBondErrorIfNeeded(e); + // Reset step back to reconnecting for UI _connectionStep = ConnectionStep.reconnecting; _connectionError = null; @@ -2061,6 +5925,48 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }); } + /// Start 15-minute idle disconnect timer. + /// Fires if user does not send a manual ping or start auto-ping within 15 minutes. + void _startIdleDisconnectTimer() { + _idleDisconnectTimer?.cancel(); + _idleDisconnectTimer = Timer(_idleDisconnectTimeout, () { + if (!isConnected || _autoPingEnabled) return; + debugLog('[IDLE] 15-minute idle timeout reached — disconnecting'); + logError('Disconnected: 15 minutes of inactivity', + severity: ErrorSeverity.warning); + disconnect(); + }); + debugLog( + '[IDLE] Idle disconnect timer started (${_idleDisconnectTimeout.inMinutes} min)'); + } + + /// Cancel the idle disconnect timer + void _cancelIdleDisconnectTimer() { + if (_idleDisconnectTimer != null) { + _idleDisconnectTimer!.cancel(); + _idleDisconnectTimer = null; + debugLog('[IDLE] Idle disconnect timer cancelled'); + } + } + + /// Detect iOS apple-code 14/15 bond errors and clear the stale bond before retry. + /// Only applies to BLE transports. + Future _handleBondErrorIfNeeded(Object error) async { + if (_selectedTransport != TransportType.ble) return; + final errorStr = error.toString(); + if (errorStr.contains('apple-code: 14') || + errorStr.contains('apple-code: 15') || + errorStr.contains('Peer removed pairing information')) { + _lastReconnectWasBondError = true; + final deviceId = _rememberedDevice?.id; + if (deviceId != null) { + debugLog( + '[CONN] Bond error detected (apple-code 14/15) — clearing stale bond for $deviceId'); + await _bluetoothService.removeBond(deviceId); + } + } + } + /// Called when auto-reconnect succeeds void _onReconnectSuccess() { // Cancel timers @@ -2077,17 +5983,37 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectAttempt = 0; _autoPingWasEnabled = false; - debugLog('[CONN] Auto-reconnect complete, restoring state (autoPing=$wasAutoPing, mode=$previousMode)'); + debugLog( + '[CONN] Auto-reconnect complete, restoring state (autoPing=$wasAutoPing, mode=$previousMode)'); // Restore auto-ping if it was active if (wasAutoPing) { + final restoreGeneration = _reconnectRestoreGeneration; // Use a short delay to ensure connection is fully set up - Timer(const Duration(milliseconds: 500), () { - if (_connectionStep == ConnectionStep.connected) { + _restoreAutoPingTimer?.cancel(); + _restoreAutoPingTimer = Timer(const Duration(milliseconds: 500), () { + _restoreAutoPingTimer = null; + if (_isDisposed || + restoreGeneration != _reconnectRestoreGeneration || + _userRequestedDisconnect || + _connectionStep != ConnectionStep.connected || + _pingService == null) { + debugLog( + '[CONN] Skipping delayed auto-ping restore (stale or disconnected state)'); + return; + } + + if (!_autoPingEnabled) { + _cooldownTimer.stop(); + _pingService!.clearCooldown(); toggleAutoPing(previousMode); - debugLog('[CONN] Auto-ping restored after reconnect (mode=$previousMode)'); + debugLog( + '[CONN] Auto-ping restored after reconnect (mode=$previousMode)'); } }); + } else { + // No auto-ping to restore — start idle timer + _startIdleDisconnectTimer(); } notifyListeners(); @@ -2106,15 +6032,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectTimer = null; _reconnectTimeoutTimer?.cancel(); _reconnectTimeoutTimer = null; + _cancelPendingAutoPingRestore(); + + // Alert if auto-ping was running before disconnect + if (_autoPingWasEnabled) { + _playDisconnectAlert(); + } // Clear reconnect state _isAutoReconnecting = false; _reconnectAttempt = 0; _autoPingWasEnabled = false; - // Reset antenna setting so user must choose again on next connect + // Reset antenna and power settings so user must choose again on next connect _antennaRestoredFromDevice = false; - _preferences = _preferences.copyWith(externalAntenna: false, externalAntennaSet: false); + _powerRestoredFromDevice = false; + _preferences = _preferences.copyWith( + externalAntenna: false, externalAntennaSet: false); _savePreferences(); // Reset anonymous mode state (BLE already gone, can't restore name) @@ -2128,25 +6062,48 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Disconnect from current device Future disconnect() async { + _finishLiveActivitySession(); // Mark as user-requested so BLE disconnect listener doesn't trigger auto-reconnect _userRequestedDisconnect = true; + // Immediate UI feedback + _connectionStep = ConnectionStep.disconnecting; + notifyListeners(); + + // Release the sign gate before any teardown write (advert-name restore, + // path-hash restore, flood scope, channel deletion) is parked behind it. + _meshCoreConnection?.abortPendingSign(); + + // Cancel idle disconnect timer + _cancelIdleDisconnectTimer(); + // Cancel any active auto-reconnect _reconnectTimer?.cancel(); _reconnectTimer = null; _reconnectTimeoutTimer?.cancel(); _reconnectTimeoutTimer = null; + _cancelPendingAutoPingRestore(); _isAutoReconnecting = false; _reconnectAttempt = 0; _autoPingWasEnabled = false; + // Cancel any active zone grace period + _cancelZoneGraceTimers(); + _isInZoneGracePeriod = false; + _zoneGraceSecondsRemaining = 0; + _autoPingWasEnabledBeforeGrace = false; + // Disable heartbeat immediately on disconnect _apiService.disableHeartbeat(); + // Stop zone refresh timer + _stopZoneRefreshTimer(); + // Stop auto-ping if running (before releasing session) if (_autoPingEnabled) { await _pingService?.forceDisableAutoPing(); _autoPingEnabled = false; + _resetIdleAutoStop(); } // End noise floor session on disconnect @@ -2163,6 +6120,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Stop RX wardriving if active (flushes batches to queue) _rxLogger?.stopWardriving(trigger: 'disconnect'); + // Save offline pings before clearing queue (no-op if not in offline mode or no pings) + await _saveOfflineSession(); + // ALWAYS START FRESH - clear any queued data on disconnect // Pings without a valid session cannot be uploaded later await _apiQueueService.clearOnDisconnect(); @@ -2186,11 +6146,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_isAnonymousRenamed && _originalDeviceName != null) { try { await _meshCoreConnection?.setAdvertName(_originalDeviceName!); - debugLog('[CONN] Anonymous mode: restored name to "$_originalDeviceName"'); + debugLog( + '[CONN] Anonymous mode: restored name to "$_originalDeviceName"'); + if (_devicePublicKey != null) { + _clearPersistedRealName(_devicePublicKey!); + } } catch (e) { debugError('[CONN] Anonymous mode: failed to restore name: $e'); - logError('Anonymous Mode: Failed to restore device name. Device may still show as "Anonymous".', - severity: ErrorSeverity.warning, autoSwitch: false); + logError( + 'Anonymous Mode: Failed to restore device name. Device may still show as "Anonymous".', + severity: ErrorSeverity.warning, + autoSwitch: false); } _isAnonymousRenamed = false; _originalDeviceName = null; @@ -2208,7 +6174,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Delete wardriving channel FIRST, while BLE connection is still active // This prevents "GATT Server is disconnected" errors - await _meshCoreConnection?.deleteWardrivingChannelEarly(); + if (_preferences.deleteChannelOnDisconnect) { + await _meshCoreConnection?.deleteWardrivingChannelEarly(); + } else { + debugLog('[CHANNEL] Skipping channel deletion (user preference)'); + } // Cleanup unified RX handler and TX tracker _logRxDataSubscription?.cancel(); @@ -2218,15 +6188,27 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _txTracker = null; // TxTracker is disposed by UnifiedRxHandler _rxLogger = null; // RxLogger is disposed by UnifiedRxHandler - // Disconnect BLE (don't call disconnect() twice - meshCoreConnection.disconnect() already does it) await _meshCoreConnection?.disconnect(); - + // Cancel stream subscriptions await _noiseFloorSubscription?.cancel(); _noiseFloorSubscription = null; await _batterySubscription?.cancel(); _batterySubscription = null; + // Clean up non-BLE transport (TCP/USB instances are owned by us, not shared) + _transportConnectionSubscription?.cancel(); + _transportConnectionSubscription = null; + if (_activeTransport != null && _activeTransport != _bluetoothService) { + _activeTransport!.dispose(); + } + _activeTransport = null; + + // Restore transport tab selection so connection screen shows the right tab + if (_rememberedDevice != null) { + _selectedTransport = _rememberedDevice!.transportType; + } + _meshCoreConnection?.dispose(); _meshCoreConnection = null; _pingService?.dispose(); @@ -2235,10 +6217,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _connectionStep = ConnectionStep.disconnected; _deviceModel = null; _manufacturerString = null; + _firmwareVersionString = null; _devicePublicKey = null; + _offlineContactUri = null; _displayDeviceName = null; _antennaRestoredFromDevice = false; - _preferences = _preferences.copyWith(externalAntenna: false, externalAntennaSet: false); + _powerRestoredFromDevice = false; + _preferences = _preferences.copyWith( + externalAntenna: false, externalAntennaSet: false); _savePreferences(); _currentNoiseFloor = null; _currentBatteryPercent = null; @@ -2246,12 +6232,25 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _originalPathHashMode = null; _userChangedPathMode = false; _hopBytes = 1; + _traceHopBytes = 1; // Clear regional channels (keeps only Public) and scope ChannelService.clearRegionalChannels(); _regionalChannels = []; _scope = null; + // Clear user-original preference tracking + _userOriginalAutoPingInterval = null; + _userOriginalHybridMode = null; + _userOriginalDiscDrop = null; + _userOriginalFloodTraffic = null; + + // Clear zone transfer state + _sessionZoneCode = null; + _isZoneTransferInProgress = false; + _zoneTransferFrom = null; + _zoneTransferTo = null; + // Clear discovered devices so user must scan fresh _discoveredDevices = []; @@ -2291,24 +6290,68 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Send a manual TX ping - Future sendPing() async { + Future sendPing({bool Function()? shouldAbortBeforeTransmit}) async { if (_pingService == null) return false; + if (_isAutoReconnecting) { + debugLog('[PING] Ignoring ping during auto-reconnect'); + return false; + } - // Check session validity before starting (skip in offline mode) - if (!_preferences.offlineMode) { - final sessionCheck = await _checkSessionBeforeAction(); - if (!sessionCheck) return false; + // Ignore re-taps while a ping is already being sent (prevents the + // double-tap / concurrent-heartbeat storm during the session check) + if (_isPingSending) { + debugLog('[PING] Ignoring tap — ping already sending'); + return false; } - // Set sending state immediately for instant UI feedback + // Set sending state immediately for instant UI feedback, BEFORE the + // (awaited, network) session check so the button locks the moment it's tapped _isPingSending = true; notifyListeners(); - debugLog('[PING] Sending manual TX ping'); + var ownsLiveActivity = false; + var keepLiveActivity = false; try { - return await _pingService!.sendTxPing(manual: true); + // Check session validity before starting (skip in offline mode) + if (!_preferences.offlineMode) { + final sessionCheck = await _checkSessionBeforeAction(); + if (!sessionCheck) return false; + } + + // The awaited session check can outlast the deadline an external surface + // is holding a person on. This is the last point before RF, so an expired + // request stops here rather than transmitting after Siri said it failed. + if (shouldAbortBeforeTransmit?.call() ?? false) { + _lastSessionCheckFailureReason = ExternalCommandReason.commandExpired; + return false; + } + + // Reset idle disconnect timer (user is actively pinging) + _startIdleDisconnectTimer(); + + debugLog('[PING] Sending manual TX ping'); + ownsLiveActivity = !_liveActivitySessionActive; + if (ownsLiveActivity) { + _startLiveActivitySession(manual: true); + } + final sent = await _pingService!.sendTxPing( + manual: true, + shouldAbortBeforeTransmit: shouldAbortBeforeTransmit, + ); + // The gate can also fire inside the send, after its own awaited work. + // Without this the caller hears the generic "couldn't send the ping" + // instead of being told the request simply arrived too late. + if (!sent && _pingService!.transmitAbortedByDeadline) { + _lastSessionCheckFailureReason = ExternalCommandReason.commandExpired; + } + keepLiveActivity = sent; + return sent; } finally { - // Clear sending state when done (RX window timer will show listening state) + if (ownsLiveActivity && !keepLiveActivity) { + _finishLiveActivitySession(); + } + // Clear sending state on every path: session-check failure, exception, + // or success (RX window timer takes over showing the listening state) _isPingSending = false; notifyListeners(); } @@ -2317,6 +6360,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Check session validity before starting a wardrive action /// Returns true if session is valid, false if expired (triggers disconnect) Future _checkSessionBeforeAction() async { + _lastSessionCheckFailureReason = null; final pos = _gpsService.lastPosition; final result = await _apiService.checkSessionValid( lat: pos?.latitude, @@ -2324,21 +6368,127 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); if (!result.isValid) { - debugWarn('[API] Session check failed: ${result.reason} - ${result.message ?? "Session expired"}'); + _lastSessionCheckFailureReason = _sessionCheckFailureMessage( + result.reason, + result.message, + ); + debugWarn( + '[API] Session check failed: ${result.reason} - ${result.message ?? "Session expired"}'); // Note: onSessionError callback will trigger disconnect for critical errors return false; } return true; } - /// Toggle auto-ping mode (Active, Passive, or Hybrid) - /// Returns false if blocked by cooldown (Active/Hybrid Mode only - Passive Mode ignores cooldown) - Future toggleAutoPing(AutoMode mode) async { + ExternalCommandReason _sessionCheckFailureMessage( + String? reason, + String? message, + ) { + // `zone_full` is the server-side form of the same TX prohibition already + // named "Passive Only" by watch controls. Reusing it avoids three wrist + // phrasings for one condition; other presentable server text stays intact. + if (reason == 'zone_full') return ExternalCommandReason.passiveOnly; + + final serverMessage = message?.trim(); + if (serverMessage != null && serverMessage.isNotEmpty) { + return ExternalCommandReason.other(serverMessage); + } + return ExternalCommandReason.other(_getErrorMessage(reason, null)); + } + + /// Set the target repeater ID for targeted mode + void setTargetRepeaterId(String? id) { + _targetRepeaterId = id; + notifyListeners(); + } + + /// Advance the idle auto-stop anchor walk by one auto-ping tick. + /// + /// Mirrors `sg_walk` in the server's stationary_guard.php: a fix within + /// [_idleAnchorRadiusMeters] of the anchor leaves the clock running, and only + /// [_idleAnchorStreakRequired] CONSECUTIVE fixes beyond it re-anchor and rearm + /// (isolated jitter spikes are one or two wild points; real movement is every + /// point far). The old rule rearmed on any ping that wasn't skipped, so GPS + /// jitter clearing the 25 m send filter kept the 30 minutes topped up + /// indefinitely and the stop never fired indoors. + void _updateIdleAutoStop() { + if (!_preferences.autoStopAfterIdle) return; + + final position = _currentPosition; + if (position == null) return; + + final anchorLat = _idleAnchorLat; + final anchorLon = _idleAnchorLon; + if (anchorLat == null || anchorLon == null) { + _anchorIdleAutoStop(position); + return; + } + + final distance = Geolocator.distanceBetween( + anchorLat, anchorLon, position.latitude, position.longitude); + + if (distance > _idleAnchorRadiusMeters) { + _idleAnchorEscapeStreak++; + if (_idleAnchorEscapeStreak >= _idleAnchorStreakRequired) { + debugLog( + '[AUTO] Idle anchor moved: ${distance.toStringAsFixed(0)}m out, ' + '$_idleAnchorEscapeStreak consecutive'); + _anchorIdleAutoStop(position); + return; + } + } else { + _idleAnchorEscapeStreak = 0; + } + + final reference = _idleAutoStopReference; + if (reference != null && + DateTime.now().difference(reference) >= _autoStopIdleTimeout) { + _triggerIdleAutoStop(); + } + } + + /// Re-anchor the idle walk here and restart the idle clock. + void _anchorIdleAutoStop(Position position) { + _idleAnchorLat = position.latitude; + _idleAnchorLon = position.longitude; + _idleAnchorEscapeStreak = 0; + _idleAutoStopReference = DateTime.now(); + } + + /// Clear the idle auto-stop clock and its anchor (auto-ping stopped). + void _resetIdleAutoStop() { + _idleAutoStopReference = null; + _idleAnchorLat = null; + _idleAnchorLon = null; + _idleAnchorEscapeStreak = 0; + } + + /// Auto-stop auto-ping after prolonged idle (no movement) + void _triggerIdleAutoStop() { + if (!_autoPingEnabled) return; + _playDisconnectAlert(); + final elapsed = _idleAutoStopReference != null + ? DateTime.now().difference(_idleAutoStopReference!).inMinutes + : 30; + debugLog('[AUTO] Auto-stop triggered: idle for $elapsed minutes'); + logError('Auto-ping stopped: no movement for 30 minutes', + severity: ErrorSeverity.warning, autoSwitch: false); + _resetIdleAutoStop(); + toggleAutoPing(_autoMode); + } + + /// Toggle auto-ping mode (Active, Passive, Hybrid, or Trace) + /// Returns false if blocked by cooldown (Active/Hybrid/Trace Mode only - Passive Mode ignores cooldown) + Future toggleAutoPing( + AutoMode mode, { + bool Function()? shouldAbortBeforeTransmit, + }) async { if (_pingService == null) return false; final isPassive = mode == AutoMode.passive; final isHybrid = mode == AutoMode.hybrid; - final isTxMode = !isPassive; // Active and Hybrid both do TX + final isTargeted = mode == AutoMode.targeted; + final isTxMode = !isPassive; // Active, Hybrid, and Targeted all do TX // If currently running the same mode, stop it (always allow stopping) if (_autoPingEnabled && _autoMode == mode) { @@ -2379,103 +6529,171 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // End noise floor session when mode is disabled await _endNoiseFloorSession(); - // Disable heartbeat when stopping auto mode - _apiService.disableHeartbeat(); + // Keep heartbeat enabled (stays on while connected to prevent session expiry) + // Re-start idle disconnect timer now that user is idle again + _startIdleDisconnectTimer(); _autoPingEnabled = false; + _resetIdleAutoStop(); + _finishLiveActivitySession(); + + // Clear top-heard overlay on stop + _clearOverlayState(); // Start 5-second shared cooldown for TX modes (Active/Hybrid), not Passive Mode // Passive Mode is listening only, no cooldown needed if (isTxMode) { _cooldownTimer.start(5000); - debugLog('[${mode.name.toUpperCase()} MODE] Shared cooldown started (5s) - blocks TX Ping and TX modes'); + debugLog( + '[${mode.name.toUpperCase()} MODE] Shared cooldown started (5s) - blocks TX Ping and TX modes'); } else { debugLog('[PASSIVE MODE] Stopped - no cooldown (listen-only mode)'); } } else { - // Check session validity before starting (skip in offline mode) - if (!_preferences.offlineMode) { - final sessionCheck = await _checkSessionBeforeAction(); - if (!sessionCheck) return false; - } + // Ignore re-taps while a start is already in flight (prevents the + // double-tap / concurrent-heartbeat storm during the session check) + if (_autoPingStarting) return false; - // Block starting if shared cooldown is active (TX modes only) - // Passive Mode is listening only and can start during cooldown - if (isTxMode && _cooldownTimer.isRunning) { - debugLog('[${mode.name.toUpperCase()} MODE] Start blocked by shared cooldown'); - return false; - } + // Set starting state immediately for instant UI feedback, BEFORE the + // (awaited, network) session check so the buttons lock the moment it's tapped + _autoPingStarting = true; + notifyListeners(); - // Stop any existing mode first - if (_autoPingEnabled) { - await _pingService!.forceDisableAutoPing(); - // Stop TX echo tracking to prevent late timer callbacks - _pingService!.stopEchoTracking(); - _rxLogger?.stopWardriving(trigger: 'mode_switch'); - await BackgroundServiceManager.stopService(); - // Stop countdown timers when switching modes - _autoPingTimer.stop(); - _rxWindowTimer.stop(); - // Save offline session if offline mode is enabled - if (_preferences.offlineMode) { - await _saveOfflineSession(); + try { + // Cancel idle disconnect timer — auto-ping keeps the session active + _cancelIdleDisconnectTimer(); + + // Check session validity before starting (skip in offline mode) + if (!_preferences.offlineMode) { + final sessionCheck = await _checkSessionBeforeAction(); + if (!sessionCheck) return false; } - // End existing noise floor session before starting new mode - await _endNoiseFloorSession(); - } - // Start new mode - debugLog('[PING] Starting auto mode: ${mode.name}'); - _autoMode = mode; + // Block starting if shared cooldown is active (TX modes only) + // Passive Mode is listening only and can start during cooldown + if (isTxMode && _cooldownTimer.isRunning) { + debugLog( + '[${mode.name.toUpperCase()} MODE] Start blocked by shared cooldown'); + return false; + } - // Set interval from user preferences before starting - final intervalMs = _preferences.autoPingInterval * 1000; - _pingService!.setAutoPingInterval(intervalMs); - debugLog('[PING] Using interval from preferences: ${_preferences.autoPingInterval}s (${intervalMs}ms)'); + // The awaited session check can outlast the deadline an external + // surface is holding a person on. Stop here, before any existing mode + // is torn down, so an expired request never starts a session the + // person was already told did not start. + if (shouldAbortBeforeTransmit?.call() ?? false) { + _lastSessionCheckFailureReason = ExternalCommandReason.commandExpired; + return false; + } - final started = await _pingService!.enableAutoPing(passiveMode: isPassive, hybridMode: isHybrid); - if (!started) { - // Blocked by cooldown or already enabled - if (_pingService!.isInCooldown()) { - debugLog('[PING] Auto mode start blocked by cooldown (${_pingService!.getRemainingCooldownSeconds()}s remaining)'); - } else { - debugLog('[PING] Auto mode start blocked'); + // Stop any existing mode first + if (_autoPingEnabled) { + await _pingService!.forceDisableAutoPing(); + // Stop TX echo tracking to prevent late timer callbacks + _pingService!.stopEchoTracking(); + _rxLogger?.stopWardriving(trigger: 'mode_switch'); + await BackgroundServiceManager.stopService(); + // Stop countdown timers when switching modes + _autoPingTimer.stop(); + _rxWindowTimer.stop(); + // Clear top-heard overlay on mode switch + _clearOverlayState(); + // Save offline session if offline mode is enabled + if (_preferences.offlineMode) { + await _saveOfflineSession(); + } + // End existing noise floor session before starting new mode + await _endNoiseFloorSession(); } - return false; - } - // Start RX wardriving for all modes - // Reference: state.rxTracking.isWardriving = true in wardrive.js - _rxLogger?.startWardriving(); - _autoPingEnabled = true; - // Start noise floor session for graph tracking - final sessionLabel = isPassive ? 'passive' : isHybrid ? 'hybrid' : 'active'; - _startNoiseFloorSession(sessionLabel); + // Start new mode + debugLog('[PING] Starting auto mode: ${mode.name}'); + _autoMode = mode; + + // Set interval from user preferences before starting + final intervalMs = _preferences.autoPingInterval * 1000; + _pingService!.setAutoPingInterval(intervalMs); + debugLog( + '[PING] Using interval from preferences: ${_preferences.autoPingInterval}s (${intervalMs}ms)'); + + // Taken before the call, not after it: enableAutoPing sends and records + // the session's first discovery before returning, and an observation + // the session made must not fall outside the session's own boundary. + final sessionStartedAt = DateTime.now(); + final started = await _pingService!.enableAutoPing( + passiveMode: isPassive, + hybridMode: isHybrid, + targetedMode: isTargeted, + targetRepeaterId: isTargeted ? _targetRepeaterId : null, + shouldAbortBeforeTransmit: shouldAbortBeforeTransmit, + ); + if (!started) { + if (_pingService!.transmitAbortedByDeadline) { + _lastSessionCheckFailureReason = + ExternalCommandReason.commandExpired; + } + // Blocked by cooldown or already enabled + if (_pingService!.isInCooldown()) { + debugLog( + '[PING] Auto mode start blocked by cooldown (${_pingService!.getRemainingCooldownSeconds()}s remaining)'); + } else { + debugLog('[PING] Auto mode start blocked'); + } + return false; + } + // Start RX wardriving for all modes + // Reference: state.rxTracking.isWardriving = true in wardrive.js + _rxLogger?.startWardriving(); + _autoPingEnabled = true; + _idleAutoStopReference = DateTime.now(); + _startLiveActivitySession(startedAt: sessionStartedAt); + + // Start noise floor session for graph tracking + final sessionLabel = isPassive + ? 'passive' + : isHybrid + ? 'hybrid' + : isTargeted + ? 'targeted' + : 'active'; + _startNoiseFloorSession(sessionLabel); + + // Enable heartbeat for all auto-ping modes (not offline mode) + // Heartbeat sends keepalive ~1 min before session expiry (4 min timer) + // Active/Hybrid pings renew session when moving, but heartbeat is the + // safety net when stationary (25m distance filter skips TX pings) + if (!_preferences.offlineMode) { + _apiService.enableHeartbeat( + gpsProvider: () { + // Provide current GPS coordinates for heartbeat (matching wardrive.js) + final pos = _gpsService.lastPosition; + if (pos == null) return null; + return (lat: pos.latitude, lon: pos.longitude); + }, + ); + debugLog('[HEARTBEAT] Enabled for ${mode.name} Mode'); + } - // Enable heartbeat for all auto-ping modes (not offline mode) - // Heartbeat sends keepalive ~1 min before session expiry (4 min timer) - // Active/Hybrid pings renew session when moving, but heartbeat is the - // safety net when stationary (25m distance filter skips TX pings) - if (!_preferences.offlineMode) { - _apiService.enableHeartbeat( - gpsProvider: () { - // Provide current GPS coordinates for heartbeat (matching wardrive.js) - final pos = _gpsService.lastPosition; - if (pos == null) return null; - return (lat: pos.latitude, lon: pos.longitude); - }, + // Start background service for continuous operation + final modeName = isPassive + ? 'Passive Mode' + : isHybrid + ? 'Hybrid Mode' + : isTargeted + ? 'Trace Mode' + : 'Active Mode'; + await BackgroundServiceManager.startService( + mode: modeName, + txCount: _pingStats.txCount, + rxCount: _pingStats.rxCount, + queueSize: _queueSize, ); - debugLog('[HEARTBEAT] Enabled for ${mode.name} Mode'); + } finally { + // Clear starting state on every path (session/cooldown/blocked early + // returns, exceptions, and success) so the buttons never stay disabled + _autoPingStarting = false; + notifyListeners(); } - - // Start background service for continuous operation - final modeName = isPassive ? 'Passive Mode' : isHybrid ? 'Hybrid Mode' : 'Active Mode'; - await BackgroundServiceManager.startService( - mode: modeName, - txCount: _pingStats.txCount, - rxCount: _pingStats.rxCount, - queueSize: _queueSize, - ); } notifyListeners(); @@ -2486,8 +6704,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void clearPings() { _txPings.clear(); _rxPings.clear(); + _discLogEntries.clear(); + _traceLogEntries.clear(); + _siriObservationRevision++; + _clearOverlayState(); _pingService?.resetStats(); - notifyListeners(); + _notifyMapNow(); } /// Clear log entries @@ -2495,8 +6717,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _txLogEntries.clear(); _rxLogEntries.clear(); _discLogEntries.clear(); + _traceLogEntries.clear(); + _siriObservationRevision++; _errorLogEntries.clear(); - notifyListeners(); + _clearOverlayState(); + _notifyMapNow(); } /// Add a discovery log entry (from Passive Mode) @@ -2505,19 +6730,52 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_discLogEntries.length > _maxLogEntries) { _discLogEntries.removeLast(); } - debugLog('[APP] Discovery log entry added: ${entry.nodeCount} nodes discovered'); - notifyListeners(); + _siriObservationRevision++; + debugLog( + '[APP] Discovery log entry added: ${entry.nodeCount} nodes discovered'); + _notifyMapNow(); + } + + /// Add a trace log entry (from Trace Mode) + void _addTraceLogEntry(TraceLogEntry entry) { + _traceLogEntries.insert(0, entry); + if (_traceLogEntries.length > _maxLogEntries) { + _traceLogEntries.removeLast(); + } + _siriObservationRevision++; + debugLog( + '[APP] Trace log entry added: target=${entry.targetRepeaterId}, success=${entry.success}'); + + // Update top repeaters overlay with successful trace result + if (entry.success && entry.localSnr != null) { + // Truncate 4-byte trace IDs to 3 bytes (6 hex chars) to fit overlay. + // The untruncated ID still travels as the row's identity: shortening it + // is a presentation decision, and resolving the name from the shortened + // form threw away a byte of certainty for no reason. + final id = entry.targetRepeaterId.toUpperCase(); + final displayId = id.length > 6 ? id.substring(0, 6) : id; + _updateTopRepeaters( + [(repeaterId: displayId, snr: entry.localSnr!)], + OverlayPingType.trace, + identities: {displayId: id}, + ); + } + + _notifyMapNow(); } /// Log a user-facing error message /// Set [autoSwitch] to false to log without navigating to error log tab - void logError(String message, {ErrorSeverity severity = ErrorSeverity.error, bool autoSwitch = true}) { + void logError(String message, + {ErrorSeverity severity = ErrorSeverity.error, bool autoSwitch = true}) { _errorLogEntries.add(UserErrorEntry( timestamp: DateTime.now(), message: message, severity: severity, )); - if (_errorLogEntries.length > _maxErrorEntries) _errorLogEntries.removeAt(0); + if (_errorLogEntries.length > _maxErrorEntries) { + _errorLogEntries.removeAt(0); + } if (autoSwitch) { _requestErrorLogSwitch = true; // Auto-switch to error log } @@ -2565,9 +6823,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // Hot-switch while connected - return enabled - ? await _switchToOfflineMode() - : await _switchToOnlineMode(); + return enabled ? await _switchToOfflineMode() : await _switchToOnlineMode(); } /// Simple offline mode change (when not connected) @@ -2577,16 +6833,27 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Offline mode ${enabled ? 'enabled' : 'disabled'}'); if (enabled) { + // Cancel zone check retries — offline mode doesn't need zone validation + _clearZoneCheckError(); + _isCheckingZone = false; + _stopMaintenancePolling(); + // Start periodic auto-save to prevent data loss from app kill + _startOfflineAutoSaveTimer(); // Clear zone data when entering offline mode _inZone = null; _currentZone = null; _nearestZone = null; _lastZoneCheckPosition = null; + _regionBorders = []; + _bordersLoadedForZone = null; debugLog('[GEOFENCE] Cleared zone data for offline mode'); } else { + // Stop auto-save timer when leaving offline mode + _stopOfflineAutoSaveTimer(); // Re-check zone status when exiting offline mode if (_currentPosition != null) { - debugLog('[GEOFENCE] Re-checking zone status after offline mode disabled'); + debugLog( + '[GEOFENCE] Re-checking zone status after offline mode disabled'); checkZoneStatus(); } } @@ -2636,11 +6903,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _preferences = _preferences.copyWith(offlineMode: true); _apiQueueService.offlineMode = true; + // 5b. Start periodic auto-save to prevent data loss from app kill + _startOfflineAutoSaveTimer(); + + // 5c. Cancel zone check retries and maintenance polling + _clearZoneCheckError(); + _isCheckingZone = false; + _stopMaintenancePolling(); + // 6. Clear zone data _inZone = null; _currentZone = null; _nearestZone = null; _lastZoneCheckPosition = null; + _regionBorders = []; + _bordersLoadedForZone = null; debugLog('[GEOFENCE] Cleared zone data for offline mode'); debugLog('[APP] Successfully switched to offline mode'); @@ -2660,6 +6937,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Hot-switching to online mode while connected'); _isSwitchingMode = true; _modeSwitchError = null; + var switchSucceeded = false; notifyListeners(); try { @@ -2677,13 +6955,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { connectedDeviceName?.replaceFirst('MeshCore-', '')); if (deviceName == null || deviceName.isEmpty) { - debugError('[APP] Cannot switch to online mode: no device name available'); + debugError( + '[APP] Cannot switch to online mode: no device name available'); _modeSwitchError = 'Device name not available'; return (success: false, error: _modeSwitchError); } if (_devicePublicKey == null) { - debugError('[APP] Cannot switch to online mode: no public key available'); + debugError( + '[APP] Cannot switch to online mode: no public key available'); _modeSwitchError = 'Device public key not available'; return (success: false, error: _modeSwitchError); } @@ -2694,38 +6974,147 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return (success: false, error: _modeSwitchError); } - debugLog('[APP] Requesting auth for online mode'); - final result = await _apiService.requestAuth( + // Clear offline mode before zone check so checkZoneStatus() doesn't skip the API call + _preferences = _preferences.copyWith(offlineMode: false); + _apiQueueService.offlineMode = false; + + debugLog('[APP] Re-checking zone status before auth...'); + await checkZoneStatus(); + + if (zoneCode == null) { + debugError('[APP] Cannot switch to online mode: not in a zone'); + _modeSwitchError = + 'Could not determine your zone. Check GPS and internet connection.'; + return (success: false, error: _modeSwitchError); + } + + // ============================================================ + // STAGE 1: Try existing public_key authentication + // ============================================================ + debugLog( + '[APP] Stage 1: Attempting auth with public_key: ${_devicePublicKey!.substring(0, 16)}...'); + + final modelString = _meshCoreConnection?.deviceModel?.manufacturer ?? + _meshCoreConnection?.deviceInfo?.manufacturer ?? + 'Unknown'; + + var result = await _apiService.requestAuth( reason: 'connect', publicKey: _devicePublicKey!, who: deviceName, appVersion: _appVersion, power: _preferences.powerLevel, iataCode: zoneCode ?? _preferences.iataCode, - model: _meshCoreConnection?.deviceModel?.manufacturer ?? - _meshCoreConnection?.deviceInfo?.manufacturer ?? 'Unknown', + model: modelString, + radioFreq: _meshCoreConnection?.selfInfo?.radioConfigApi, lat: _currentPosition!.latitude, lon: _currentPosition!.longitude, accuracyMeters: _currentPosition!.accuracy, ); - if (result == null) { - debugError('[APP] Auth request failed: no response'); - _modeSwitchError = 'Unable to connect to server'; + // Check for maintenance mode + if (result != null && result['maintenance'] == true) { + _maintenanceMode = true; + _maintenanceMessage = result['maintenance_message'] as String?; + _maintenanceUrl = result['maintenance_url'] as String?; + debugLog( + '[MAINTENANCE] Auth returned maintenance: $_maintenanceMessage'); + _startMaintenancePolling(); + notifyListeners(); + _modeSwitchError = + _maintenanceMessage ?? 'Service is under maintenance'; return (success: false, error: _modeSwitchError); } - if (result['success'] != true) { - final reason = result['reason'] as String?; - final message = result['message'] as String?; - debugError('[APP] Auth request failed: $reason - $message'); - _modeSwitchError = message ?? reason ?? 'Authentication failed'; + // Check if Stage 1 succeeded + if (result != null && result['success'] == true) { + debugLog('[APP] Stage 1 succeeded: authenticated via public_key'); + if (result['type'] != null) { + _authType = result['type'] as String; + debugLog('[APP] Auth type: $_authType'); + notifyListeners(); + } + _syncZoneCapacityFromAuth(result); + } else if (result == null) { + // API unreachable (null = network/timeout error) + debugError('[APP] API unreachable - network error'); + _modeSwitchError = 'Unable to reach the MeshMapper server'; return (success: false, error: _modeSwitchError); + } else { + // Stage 1 failed — check if Stage 2 is worth attempting + debugLog( + '[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); + + final stage1Reason = result['reason'] as String?; + if (stage1Reason == 'gps_inaccurate' || stage1Reason == 'gps_stale') { + debugError( + '[APP] Stage 1 failed for GPS reason ($stage1Reason), skipping Stage 2'); + _modeSwitchError = result['message'] as String? ?? 'GPS error'; + return (success: false, error: _modeSwitchError); + } + + // ============================================================ + // STAGE 2: Auth failed, attempt registration via signed contact_uri + // ============================================================ + debugLog('[APP] Stage 2: Attempting registration via contact_uri...'); + + String? contactUri; + try { + debugLog('[APP] Requesting signed contact URI from device...'); + contactUri = await _meshCoreConnection!.exportContact(); + debugLog( + '[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); + } catch (e) { + debugError('[APP] Failed to get contact URI from device: $e'); + _modeSwitchError = + 'Companion not found in backend and failed to register via API'; + return (success: false, error: _modeSwitchError); + } + + final registerResult = await _apiService.requestAuth( + reason: 'register', + contactUri: contactUri, + who: deviceName, + appVersion: _appVersion, + power: _preferences.powerLevel, + iataCode: zoneCode ?? _preferences.iataCode, + model: modelString, + radioFreq: _meshCoreConnection?.selfInfo?.radioConfigApi, + lat: _currentPosition!.latitude, + lon: _currentPosition!.longitude, + accuracyMeters: _currentPosition!.accuracy, + ); + + if (registerResult == null) { + debugError('[APP] Stage 2 failed: network error (API unreachable)'); + _modeSwitchError = 'Unable to reach the MeshMapper server'; + return (success: false, error: _modeSwitchError); + } + + if (registerResult['success'] != true) { + final serverReason = + registerResult['reason'] as String? ?? 'registration_failed'; + final serverMessage = registerResult['message'] as String?; + debugError( + '[APP] Stage 2 failed: $serverReason - ${serverMessage ?? 'no message'}'); + _modeSwitchError = serverMessage ?? 'Registration rejected by server'; + return (success: false, error: _modeSwitchError); + } + + // Registration successful + debugLog('[APP] Stage 2 succeeded: registered and authenticated'); + if (registerResult['type'] != null) { + _authType = registerResult['type'] as String; + debugLog('[APP] Auth type: $_authType'); + notifyListeners(); + } + _syncZoneCapacityFromAuth(registerResult); + + result = registerResult; } // 5. Auth successful - update state - _preferences = _preferences.copyWith(offlineMode: false); - _apiQueueService.offlineMode = false; + switchSucceeded = true; // 6. Update regional channels from auth response final channels = result['channels']; @@ -2737,11 +7126,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await ChannelService.setRegionalChannels(_regionalChannels); } - // 7. Re-check zone status - if (_currentPosition != null) { - debugLog('[GEOFENCE] Re-checking zone status after online mode enabled'); - await checkZoneStatus(); - } + // Track session zone for zone-to-zone transfer detection + _sessionZoneCode = zoneCode; debugLog('[APP] Successfully switched to online mode'); return (success: true, error: null); @@ -2750,6 +7136,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _modeSwitchError = 'Failed to switch to online mode: $e'; return (success: false, error: _modeSwitchError); } finally { + if (!switchSucceeded) { + _preferences = _preferences.copyWith(offlineMode: true); + _apiQueueService.offlineMode = true; + } _isSwitchingMode = false; notifyListeners(); } @@ -2794,6 +7184,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 9. Update state _autoPingEnabled = false; + _resetIdleAutoStop(); + _finishLiveActivitySession(); debugLog('[APP] Auto-ping mode stopped gracefully'); notifyListeners(); } @@ -2825,28 +7217,95 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return _switchToOnlineMode(); } + /// Device name recorded on an offline session. + /// + /// Mirrors the live auth path (`who:`): a session captured while Anonymous + /// Mode is on uploads as "Anonymous" too. Recording the real name here + /// leaked it on a later sync, since the mode may well be off by then. + String? get _offlineDeviceName => _isAnonymousRenamed + ? 'Anonymous' + : (_meshCoreConnection?.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); + /// Save accumulated offline pings to a session file Future _saveOfflineSession() async { final pings = _apiQueueService.getAndClearOfflinePings(); if (pings.isEmpty) { debugLog('[APP] No offline pings to save'); + // Still break the auto-save tracker so the next offline session starts a + // fresh file instead of appending to the previously tracked session. + // No-op when nothing is tracked. + _offlineSessionService.finalizeCurrentSession(); return; } - // Include device info for auth during upload (use real name, not "Anonymous" — sessions upload later) + // Include device info for auth during upload. // Note: Connection already validates device name exists, so this should never be null - final offlineDeviceName = _isAnonymousRenamed - ? _originalDeviceName - : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); - await _offlineSessionService.saveSession( + final offlineDeviceName = _offlineDeviceName; + // Finalize the in-progress session (created by periodic auto-save) in place + // rather than creating a new one — otherwise the auto-saved session and this + // final save become two identical sessions at the same time. updateCurrentSession + // creates a fresh session only when no auto-save has run yet. + await _offlineSessionService.updateCurrentSession( pings, devicePublicKey: _devicePublicKey, deviceName: offlineDeviceName, + contactUri: _offlineContactUri, + radioConfig: _meshCoreConnection?.selfInfo?.radioConfigApi, + deviceModel: _meshCoreConnection?.deviceModel?.manufacturer ?? + _meshCoreConnection?.deviceInfo?.manufacturer ?? + _manufacturerString, + powerLevel: _preferences.powerLevel, + appVersion: _appVersion, ); + _offlineSessionService.finalizeCurrentSession(); debugLog('[APP] Saved offline session with ${pings.length} pings'); + _stopOfflineAutoSaveTimer(); notifyListeners(); } + /// Periodically auto-save offline pings to prevent data loss from app kill. + /// Uses a non-destructive snapshot so in-memory accumulation continues. + void _autoSaveOfflinePings() { + if (!_preferences.offlineMode || _apiQueueService.offlinePingCount == 0) { + return; + } + + final pings = _apiQueueService.getOfflinePingsSnapshot(); + if (pings.isEmpty) return; + + final offlineDeviceName = _offlineDeviceName; + + _offlineSessionService.updateCurrentSession( + pings, + devicePublicKey: _devicePublicKey, + deviceName: offlineDeviceName, + contactUri: _offlineContactUri, + radioConfig: _meshCoreConnection?.selfInfo?.radioConfigApi, + deviceModel: _meshCoreConnection?.deviceModel?.manufacturer ?? + _meshCoreConnection?.deviceInfo?.manufacturer ?? + _manufacturerString, + powerLevel: _preferences.powerLevel, + appVersion: _appVersion, + ); + } + + void _startOfflineAutoSaveTimer() { + _offlineAutoSaveTimer?.cancel(); + _offlineAutoSaveTimer = Timer.periodic(const Duration(seconds: 60), (_) { + _autoSaveOfflinePings(); + }); + debugLog('[OFFLINE] Auto-save timer started (60s interval)'); + } + + void _stopOfflineAutoSaveTimer() { + if (_offlineAutoSaveTimer != null) { + _offlineAutoSaveTimer!.cancel(); + _offlineAutoSaveTimer = null; + debugLog('[OFFLINE] Auto-save timer stopped'); + } + } + /// Upload a stored offline session Future uploadOfflineSession(String filename) async { final sessionData = _offlineSessionService.getSessionData(filename); @@ -2871,7 +7330,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (success) { // Delete the session file on successful upload await _offlineSessionService.deleteSession(filename); - debugLog('[API] Uploaded and deleted offline session: $filename (${pings.length} pings)'); + debugLog( + '[API] Uploaded and deleted offline session: $filename (${pings.length} pings)'); } else { debugError('[API] Failed to upload offline session: $filename'); } @@ -2884,14 +7344,44 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Upload an offline session with authenticated API session - /// Uses stored device credentials to authenticate before uploading + /// Uses stored device credentials to authenticate before uploading. + /// Session is fully isolated from the shared ApiService state — offline uploads + /// never touch _sessionId and cannot trigger BLE disconnect on failure. /// + /// @param onProgress Optional callback for progress updates (e.g., "Batch 1/3") /// Returns the result of the upload operation - Future uploadOfflineSessionWithAuth(String filename) async { + Future uploadOfflineSessionWithAuth( + String filename, { + void Function(String status)? onProgress, + }) async { + // Concurrency guard — only one offline upload at a time + if (_isUploadingOfflineSession) { + debugWarn( + '[OFFLINE] Upload already in progress, rejecting concurrent request'); + return OfflineUploadResult.uploadInProgress; + } + + _isUploadingOfflineSession = true; + notifyListeners(); + + try { + return await _uploadOfflineSessionIsolated(filename, + onProgress: onProgress); + } finally { + _isUploadingOfflineSession = false; + notifyListeners(); + } + } + + /// Internal implementation of offline session upload with isolated session + Future _uploadOfflineSessionIsolated( + String filename, { + void Function(String status)? onProgress, + }) async { // 1. Get session with stored device credentials final session = _offlineSessionService.getSession(filename); if (session == null) { - debugLog('[APP] Offline session not found: $filename'); + debugLog('[OFFLINE] Session not found: $filename'); return OfflineUploadResult.notFound; } @@ -2902,90 +7392,303 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { .toList(); if (pings == null || pings.isEmpty) { - debugLog('[APP] Offline session has no pings: $filename'); + debugLog('[OFFLINE] Session has no pings: $filename'); return OfflineUploadResult.invalidSession; } - // 2. Get device credentials from session - final publicKey = session.devicePublicKey; - if (publicKey == null) { - debugLog('[APP] Offline session missing device public key: $filename'); - return OfflineUploadResult.invalidSession; + // 2. Get device credentials from session, falling back to connected device + var publicKey = session.devicePublicKey; + var deviceName = session.deviceName; + + if (publicKey == null || deviceName == null || deviceName.isEmpty) { + if (_devicePublicKey != null && displayDeviceName != null) { + publicKey ??= _devicePublicKey; + if (deviceName == null || deviceName.isEmpty) { + deviceName = displayDeviceName; + } + debugLog( + '[OFFLINE] Legacy session $filename: using connected device credentials'); + } else { + debugLog( + '[OFFLINE] Session missing credentials and no device connected: $filename'); + return OfflineUploadResult.invalidSession; + } } - final deviceName = session.deviceName; - if (deviceName == null || deviceName.isEmpty) { - debugLog('[APP] Offline session missing device name: $filename'); - return OfflineUploadResult.invalidSession; + onProgress?.call('Authenticating...'); + + // 3. Check GPS before auth — the server requires current coordinates for geo-auth + if (_currentPosition == null) { + debugError( + '[OFFLINE] Upload requires GPS - location services not available'); + return OfflineUploadResult.gpsRequired; } - // 3. Authenticate with offline_mode: true - debugLog('[APP] Authenticating for offline upload with device: $deviceName'); - final authResult = await _apiService.requestAuth( - reason: 'connect', - publicKey: publicKey, - who: deviceName, - appVersion: _appVersion, - power: _preferences.powerLevel, - iataCode: zoneCode ?? _preferences.iataCode, - model: 'Offline Upload', - lat: _currentPosition?.latitude, - lon: _currentPosition?.longitude, - accuracyMeters: _currentPosition?.accuracy, - offlineMode: true, - ); + // 4. Authenticate with offline_mode: true, skipSessionStore: true + // This prevents writing to shared _sessionId/_txAllowed/etc. + // Use the device/radio metadata snapshotted when the session was recorded so the upload + // matches a live session (feature parity); fall back to current values for legacy + // sessions saved before these fields were captured. + final uploadModel = session.deviceModel ?? 'Offline Upload'; + final uploadPower = session.powerLevel ?? _preferences.powerLevel; + final uploadVersion = session.appVersion ?? _appVersion; + debugLog( + '[OFFLINE] Authenticating for offline upload with device: $deviceName ' + '(model: $uploadModel, power: ${uploadPower}w, ver: $uploadVersion)'); + // Retry the auth on transient network/timeout errors — requestAuth returns null + // on a TimeoutException. A single slow first request shouldn't abort the upload + // and surface a misleading "auth failed" (mirrors the batch-upload retry below). + const authRetryBackoff = [2, 4]; // seconds, after the initial attempt + Map? authResult; + for (var attempt = 0;; attempt++) { + authResult = await _apiService.requestAuth( + reason: 'connect', + publicKey: publicKey, + who: deviceName, + appVersion: uploadVersion, + power: uploadPower, + iataCode: zoneCode ?? _preferences.iataCode, + model: uploadModel, + radioFreq: session.radioConfig, + lat: _currentPosition?.latitude, + lon: _currentPosition?.longitude, + accuracyMeters: _currentPosition?.accuracy, + offlineMode: true, + skipSessionStore: true, + ); + if (authResult != null) { + break; // got a response (success OR a real rejection) + } + if (attempt >= authRetryBackoff.length) break; // retries exhausted + final delay = authRetryBackoff[attempt]; + debugWarn( + '[OFFLINE] Auth network error, retry ${attempt + 1}/${authRetryBackoff.length} after ${delay}s'); + onProgress?.call('Authenticating (retry ${attempt + 1})...'); + await Future.delayed(Duration(seconds: delay)); + } + + Map? effectiveAuth = authResult; + + if (authResult == null) { + debugError('[OFFLINE] Auth failed: network error after retries'); + return OfflineUploadResult.networkError; + } + + if (authResult['success'] != true) { + final reason = authResult['reason'] as String? ?? 'unknown'; + debugLog('[OFFLINE] Stage 1 failed: $reason'); + + // Stage 2: If unknown_device and we have a stored contactUri, attempt registration + if (reason == 'unknown_device' && session.contactUri != null) { + debugLog( + '[OFFLINE] Stage 2: Attempting registration via stored contact URI...'); + final registerResult = await _apiService.requestAuth( + reason: 'register', + contactUri: session.contactUri, + who: deviceName, + appVersion: uploadVersion, + power: uploadPower, + iataCode: zoneCode ?? _preferences.iataCode, + model: uploadModel, + radioFreq: session.radioConfig, + lat: _currentPosition?.latitude, + lon: _currentPosition?.longitude, + accuracyMeters: _currentPosition?.accuracy, + offlineMode: true, + skipSessionStore: true, + ); + + if (registerResult == null) { + debugError('[OFFLINE] Stage 2 registration network error'); + return OfflineUploadResult.networkError; + } + if (registerResult['success'] != true) { + final regReason = registerResult['reason'] as String? ?? 'unknown'; + debugError('[OFFLINE] Stage 2 registration failed: $regReason'); + return OfflineUploadResult.authFailed; + } + + debugLog( + '[OFFLINE] Stage 2 succeeded: device registered for offline upload'); + effectiveAuth = registerResult; + } else { + debugError('[OFFLINE] Auth failed: $reason'); + return OfflineUploadResult.authFailed; + } + } - if (authResult == null || authResult['success'] != true) { - final reason = authResult?['reason'] as String? ?? 'unknown'; - debugError('[API] Offline upload auth failed: $reason'); + // Extract session_id into local variable — never stored in shared state + final offlineSessionId = effectiveAuth!['session_id'] as String?; + final uploadPublicKey = publicKey!; + if (offlineSessionId == null) { + debugError('[OFFLINE] Auth succeeded but no session_id in response'); return OfflineUploadResult.authFailed; } - debugLog('[APP] Offline upload authenticated, session: ${authResult['session_id']}'); + debugLog( + '[OFFLINE] Authenticated with isolated session: $offlineSessionId'); - // Delay after auth before posting - await Future.delayed(const Duration(seconds: 1)); + // Server can take several seconds to make a freshly-created offline session + // visible to /wardrive (read-after-write propagation). Give it a brief settle, + // then let the FIRST batch wait it out with a generous backoff — once any batch + // lands the session is valid for the rest. + await Future.delayed(const Duration(seconds: 2)); - // 4. Upload pings in batches of 50 + // 4. Upload pings in batches of 50, retrying session/transient errors. const batchSize = 50; var uploadedCount = 0; - var failedBatches = 0; + final totalBatches = (pings.length + batchSize - 1) ~/ batchSize; + + // Accumulate the server's per-region placement summary across all batches so the uploaded + // session can show where its pings landed (e.g. "DSA 88 · EMA 157 · too far 3"). Offline + // uploads route each ping to its own region server-side; these come back per batch. + final Map placementTotals = {}; + var tooFarTotal = 0; + void accumulatePlacement(Map resp) { + final pc = resp['placement_counts']; + if (pc is Map) { + pc.forEach((k, v) { + final n = (v is int) ? v : (int.tryParse('$v') ?? 0); + placementTotals[k.toString()] = + (placementTotals[k.toString()] ?? 0) + n; + }); + } + final tf = resp['too_far_region']; + if (tf is int) { + tooFarTotal += tf; + } else if (tf != null) { + tooFarTotal += int.tryParse('$tf') ?? 0; + } + } + + // Backoff (seconds) for session-propagation / transient errors. The first batch + // absorbs the session-propagation delay, so it gets a much longer budget than + // later batches (which only see a session error if it genuinely expired/revoked). + const firstBatchBackoff = [2, 3, 4, 5, 6, 8, 10]; + const laterBatchBackoff = [2, 4]; for (var i = 0; i < pings.length; i += batchSize) { + final batchNum = (i ~/ batchSize) + 1; + onProgress?.call('Batch $batchNum/$totalBatches'); + final batch = pings.skip(i).take(batchSize).toList(); - final result = await _apiService.uploadBatch(batch); + final backoff = i == 0 ? firstBatchBackoff : laterBatchBackoff; + + var result = await _apiService.uploadBatchWithSessionId( + batch, offlineSessionId, + onResponse: accumulatePlacement); + + // Retry only session-propagation / transient errors. nonRetryable + // (data/zone/key) errors are NOT retried — we stop and preserve instead. + for (var retry = 0; + retry < backoff.length && + (result == UploadResult.sessionError || + result == UploadResult.retryable); + retry++) { + final delay = backoff[retry]; + final kind = + result == UploadResult.sessionError ? 'session' : 'transient'; + debugLog( + '[OFFLINE] Batch $batchNum $kind error, retry ${retry + 1}/${backoff.length} after ${delay}s'); + onProgress?.call('Batch $batchNum/$totalBatches (retry ${retry + 1})'); + await Future.delayed(Duration(seconds: delay)); + result = await _apiService.uploadBatchWithSessionId( + batch, offlineSessionId, + onResponse: accumulatePlacement); + } + if (result == UploadResult.success) { uploadedCount += batch.length; - debugLog('[APP] Uploaded batch ${(i ~/ batchSize) + 1}: ${batch.length} pings'); - } else { - failedBatches++; - debugError('[APP] Failed to upload batch ${(i ~/ batchSize) + 1}'); + debugLog('[OFFLINE] Uploaded batch $batchNum: ${batch.length} pings'); + _forwardOfflineBatchToCustomApi( + batch, + batchNum: batchNum, + publicKey: uploadPublicKey, + auth: effectiveAuth, + ); + continue; } + + // Any non-success after retries: STOP and preserve the remaining pings. + // We never discard un-uploaded data — it stays in the file for a later retry. + final stopReason = result == UploadResult.sessionError + ? 'session error' + : result == UploadResult.nonRetryable + ? 'data/zone error' + : 'network error'; + debugWarn( + '[OFFLINE] Batch $batchNum stopped ($stopReason) — preserving remaining pings'); + break; } // Delay after posting before disconnect await Future.delayed(const Duration(seconds: 1)); - // 5. Release API session + // 5. Release isolated API session (does not clear shared state) + onProgress?.call('Finalizing...'); await _apiService.requestAuth( reason: 'disconnect', publicKey: publicKey, + sessionId: offlineSessionId, ); - debugLog('[APP] Offline upload session released'); + debugLog('[OFFLINE] Isolated upload session released'); + + // 6. Clean up session based on results — prune ONLY successfully-uploaded + // pings; everything not uploaded is preserved in the file for a later retry. + final remainingPings = pings.length - uploadedCount; - // 6. Mark session as uploaded (don't delete) if all batches succeeded - if (failedBatches == 0) { - await _offlineSessionService.markAsUploaded(filename); - debugLog('[API] Uploaded ${pings.length} pings from $filename'); + if (remainingPings <= 0) { + await _offlineSessionService.markAsUploaded( + filename, + placementCounts: placementTotals.isNotEmpty ? placementTotals : null, + tooFarRegion: tooFarTotal, + ); + debugLog( + '[OFFLINE] Session complete: $uploadedCount uploaded from $filename'); notifyListeners(); return OfflineUploadResult.success; } else { - debugWarn('[API] Partial upload: $uploadedCount/${pings.length} pings from $filename'); + if (uploadedCount > 0) { + await _offlineSessionService.removeProcessedPings( + filename, uploadedCount); + debugLog( + '[OFFLINE] Removed $uploadedCount uploaded pings, $remainingPings preserved in $filename'); + } + debugWarn( + '[OFFLINE] Partial upload: $uploadedCount uploaded, $remainingPings preserved in $filename'); notifyListeners(); return OfflineUploadResult.partialFailure; } } + /// Forward a successfully uploaded offline batch to the Custom API endpoint. + /// + /// The whole batch is stamped with the upload-location zone (the fresh + /// /auth at the current GPS position), not where the pings were recorded — + /// the client cannot compute per-ping zones for sessions that span regions. + void _forwardOfflineBatchToCustomApi( + List> batch, { + required int batchNum, + required String publicKey, + required Map auth, + }) { + final contact = + publicKey.length >= 8 ? publicKey.substring(0, 8).toUpperCase() : null; + var iata = zoneCode ?? _preferences.iataCode; + final zone = auth['zone']; + if (zone is Map && zone['code'] != null) { + iata = zone['code'].toString(); + } + debugLog('[OFFLINE] Custom API forward for batch $batchNum ' + '(${batch.length} pings, contact=$contact, iata=$iata)'); + _customApiService.forwardPings( + batch, + contactOverride: contact, + iataOverride: iata, + source: 'offline', + ); + } + /// Delete an offline session without uploading Future deleteOfflineSession(String filename) async { await _offlineSessionService.deleteSession(filename); @@ -3004,20 +7707,54 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Update user preferences void updatePreferences(UserPreferences preferences) { - debugLog('[APP] Preferences updated: externalAntennaSet=${preferences.externalAntennaSet}, ' + final liveActivityLayoutChanged = + preferences.liveActivityShowNames != _preferences.liveActivityShowNames; + debugLog( + '[APP] Preferences updated: externalAntennaSet=${preferences.externalAntennaSet}, ' 'externalAntenna=${preferences.externalAntenna}, autoPowerSet=${preferences.autoPowerSet}'); _preferences = preferences; - // Clear restored flag — user is making a manual choice now + // Update user-original baseline when user changes zone-overridable settings + if (_userOriginalAutoPingInterval != null) { + _userOriginalAutoPingInterval = preferences.autoPingInterval; + _userOriginalHybridMode = preferences.hybridModeEnabled; + _userOriginalDiscDrop = preferences.discDropEnabled; + _userOriginalFloodTraffic = preferences.floodTrafficEnabled; + } + + // Clear restored flags — user is making a manual choice now _antennaRestoredFromDevice = false; + _powerRestoredFromDevice = false; // Persist antenna choice per device name (use original name, not "Anonymous") - final deviceName = _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; + final deviceName = + _isAnonymousRenamed ? _originalDeviceName : displayDeviceName; if (deviceName != null && preferences.externalAntennaSet) { _deviceAntennaPreferences[deviceName] = preferences.externalAntenna; _saveDeviceAntennaPreferences(); - debugLog('[APP] Saved antenna preference for "$deviceName": ${preferences.externalAntenna ? "external" : "device"}'); + debugLog( + '[APP] Saved antenna preference for "$deviceName": ${preferences.externalAntenna ? "external" : "device"}'); + } + + // Persist power override per device name + if (deviceName != null && + preferences.powerLevelSet && + !preferences.autoPowerSet) { + _devicePowerOverrides[deviceName] = { + 'powerLevel': preferences.powerLevel, + 'txPower': preferences.txPower, + }; + _saveDevicePowerOverrides(); + debugLog( + '[APP] Saved power override for "$deviceName": ${preferences.powerLevel}W'); + } else if (deviceName != null && preferences.autoPowerSet) { + // User re-selected the auto-detected value — clear any saved override + if (_devicePowerOverrides.remove(deviceName) != null) { + _saveDevicePowerOverrides(); + debugLog( + '[APP] Cleared power override for "$deviceName" (auto-detected selected)'); + } } // Propagate RSSI filter setting to live trackers/validators @@ -3026,7 +7763,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Propagate CARpeater prefix to live trackers _syncCarpeaterPrefix(); - notifyListeners(); + // Propagate min ping distance to GpsService and PingService + _gpsService + .setMinPingDistance(preferences.minPingDistanceMeters.toDouble()); + PingService.currentMinDistance = preferences.minPingDistanceMeters; + + // Marker-style / GPS-marker prefs can change here — bump the map. + _notifyMapNow(); + // The Live Activity row layout follows a preference now. Only that flip + // schedules a sync (the snapshot build walks the full ping history, too + // expensive for unrelated toggles), and the flag sits in the urgency key, + // which is what carries it past the 15 second non-urgent floor. + if (liveActivityLayoutChanged) { + _scheduleLiveActivitySync(immediate: true); + } _savePreferences(); } @@ -3039,22 +7789,32 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); // If connected, disconnect and reconnect for clean auth session - if (_connectionStatus == ConnectionStatus.connected && _meshCoreConnection != null) { + if (_connectionStatus == ConnectionStatus.connected && + _meshCoreConnection != null) { final deviceToReconnect = _bluetoothService.connectedDevice; if (deviceToReconnect != null) { - _requestConnectionTabSwitch = true; + _isAnonymousReconnectInProgress = true; + _anonymousReconnectEnabling = enabled; + _connectionStep = ConnectionStep.disconnected; notifyListeners(); - await disconnect(); // Full cleanup (restores name if previously anonymous) - // Short delay for BLE cleanup - await Future.delayed(const Duration(milliseconds: 500)); - await connectToDevice(deviceToReconnect); + try { + await disconnect(); + await Future.delayed(const Duration(milliseconds: 500)); + await connectToDevice(deviceToReconnect); + } catch (e) { + debugError('[APP] Anonymous mode reconnect error: $e'); + } finally { + _isAnonymousReconnectInProgress = false; + notifyListeners(); + } } } } /// Propagate carpeaterPrefix to live TxTracker and RxLogger void _syncCarpeaterPrefix() { - final prefix = _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; + final prefix = + _preferences.ignoreCarpeater ? _preferences.ignoreRepeaterId : null; if (_txTracker != null) { _txTracker!.carpeaterPrefix = prefix; debugLog('[APP] Synced TxTracker.carpeaterPrefix = ${prefix ?? 'null'}'); @@ -3091,10 +7851,34 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _savePreferences(); } - /// Set map style (dark, light, satellite) and persist + /// Set map style (dark, light, satellite) and persist. + /// The base map style is map-rendered state: `MapWidget._buildMap` reads + /// `preferences.mapStyle` and feeds it to `MapLibreMap.styleString`, but the + /// map is isolated behind the `mapRevision` Selector (see Critical Rule 9) and + /// uses `context.read`. Plain `notifyListeners()` leaves `mapRevision` + /// untouched, so the map never rebuilds and the new style never reaches the + /// native `setStyle` — the log fires but the map stays on the old style. Bump + /// the revision via `_notifyMapNow()` so the rebuild applies the style. void setMapStyle(String style) { _preferences = _preferences.copyWith(mapStyle: style); debugLog('[MAP] Map style set to $style'); + _notifyMapNow(); + _savePreferences(); + } + + /// Set coverage overlay opacity (0.3 to 1.0) and persist. + /// This is UI-only state and must NOT bump mapRevision: a rebuild per slider + /// step would relayout the map platform view. MapWidget picks the new value + /// up through its direct provider listener (`_onCoverageOpacityNotify`) and + /// pushes it into the live fill layers via setLayerProperties, so the overlay + /// fades without a rebuild. A build() watcher cannot do this, because the map + /// sits behind the mapRevision Selector and never rebuilds here (#434). + /// Lower bound of 0.3 prevents the overlay from disappearing entirely. + void setCoverageOverlayOpacity(double opacity) { + final clamped = opacity.clamp(0.3, 1.0); + _preferences = _preferences.copyWith(coverageOverlayOpacity: clamped); + debugLog( + '[MAP] Coverage overlay opacity set to ${clamped.toStringAsFixed(2)}'); notifyListeners(); _savePreferences(); } @@ -3107,6 +7891,24 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _savePreferences(); } + /// Set color vision type for accessibility and persist + void setColorVisionType(String type) { + _preferences = _preferences.copyWith(colorVisionType: type); + PingColors.setColorVisionType( + ColorVisionType.values.firstWhere((e) => e.name == type, + orElse: () => ColorVisionType.none), + ); + debugLog('[A11Y] Color vision type set to $type'); + // Map-rendered state: the CVD palette recolours both the coverage overlay + // (detected via `overlayPrefChanged` in `MapWidget._buildMap`) and the ping + // markers (`PingColors`). Both only re-apply on a map rebuild, which is + // gated by `mapRevision` (Critical Rule 9) — plain `notifyListeners()` would + // update the preference but leave the map on the old colours. Bump the + // revision via `_notifyMapNow()` so the rebuild applies the new palette. + _notifyMapNow(); + _savePreferences(); + } + /// Set unit system preference (metric or imperial) void setUnitSystem(String system) { _preferences = _preferences.copyWith(unitSystem: system); @@ -3159,12 +7961,55 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + /// Set TX sound enabled state (ping sent / discovery sent) + Future setTxSoundEnabled(bool enabled) async { + await _audioService.setTxEnabled(enabled); + notifyListeners(); + } + + /// Set RX sound enabled state (repeater echo / RX observation) + Future setRxSoundEnabled(bool enabled) async { + await _audioService.setRxEnabled(enabled); + notifyListeners(); + } + + /// Set disconnect alert enabled state + Future setDisconnectAlertEnabled(bool enabled) async { + _preferences = _preferences.copyWith(disconnectAlertEnabled: enabled); + await _savePreferences(); + debugLog('[AUDIO] Disconnect alert ${enabled ? 'enabled' : 'disabled'}'); + notifyListeners(); + } + + /// Broadcast my coordinates: when true, TX pings put real GPS on the air + /// (legacy). When false (default), pings broadcast the privacy-preserving wire + /// tag and coords travel only via the API. + Future setBroadcastCoords(bool enabled) async { + _preferences = _preferences.copyWith(broadcastCoords: enabled); + await _savePreferences(); + debugLog( + '[PING] Broadcast coordinates ${enabled ? 'enabled' : 'disabled'}'); + notifyListeners(); + } + + /// Play disconnect alert if enabled (triple beep for unexpected ping stop) + void _playDisconnectAlert() { + if (!_audioService.isEnabled || !_preferences.disconnectAlertEnabled) { + return; + } + debugLog('[AUDIO] Playing disconnect alert — pinging stopped unexpectedly'); + _audioService.playAlertSound(); + } + /// Navigate to coordinates on map (triggered from log entries) void navigateToMapCoordinates(double latitude, double longitude) { _mapNavigationTarget = (lat: latitude, lon: longitude); _mapNavigationTrigger++; // Increment to trigger listeners _requestMapTabSwitch = true; // Request tab switch - notifyListeners(); + // Map-relevant: the build()-side nav block reads mapNavigationTrigger, so + // the map must rebuild for the camera jump to fire (GPS no longer forces + // frequent rebuilds after the position/map-rebuild decouple). + _notifyMapNow(); } /// Clear the map tab switch request (called by main scaffold after switching) @@ -3177,18 +8022,22 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _requestErrorLogSwitch = false; } - /// Clear the connection tab switch request (called by main scaffold after switching) - void clearConnectionTabSwitchRequest() { - _requestConnectionTabSwitch = false; - } - // ============================================ // API Error Handling // ============================================ /// Handle API error codes with user-friendly messages /// Returns a user-friendly message for the error code - String _getErrorMessage(String? reason, String? serverMessage) { + String _getErrorMessage(String? reason, String? serverMessage, + {String? subReason}) { + // Stationary revoke: the server's message names the actual cause and the + // window it measured, which the hardcoded text below cannot. Show it as-is. + if (reason == 'session_revoked' && + subReason == 'stationary' && + serverMessage != null && + serverMessage.isNotEmpty) { + return serverMessage; + } switch (reason) { case 'unknown_device': return 'Unknown device. Please advertise yourself on the mesh using the official MeshCore app.'; @@ -3199,7 +8048,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { case 'zone_full': return 'Zone is at TX capacity. You can still receive (RX-only mode).'; case 'gps_stale': - return 'GPS data is too old. Acquiring fresh position...'; + return "Your phone's clock is out of sync. Turn on automatic date and time in your phone settings."; case 'gps_inaccurate': return 'GPS accuracy insufficient (need <50m). Waiting for better signal...'; case 'bad_key': @@ -3208,6 +8057,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return serverMessage ?? 'Invalid request to API.'; case 'session_expired': return 'Session has expired. Please reconnect.'; + case 'session_limit': + return 'Reached session limit. Please reconnect to continue.'; case 'bad_session': return 'Invalid session. Please reconnect.'; case 'outofdate': @@ -3226,6 +8077,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return 'Service is under maintenance. Try again later.'; case 'network_error': return 'Unable to connect to the MeshMapper server. Please check your internet connection and try again.'; + case 'clock_error': + return serverMessage ?? + 'Device clock error. Power-cycle your device to reset it.'; default: return serverMessage ?? 'Unknown error occurred.'; } @@ -3233,18 +8087,54 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Handle session error from wardrive/heartbeat API calls /// This may trigger auto-disconnect - Future handleSessionError(String? reason, String? message) async { - final userMessage = _getErrorMessage(reason, message); + Future handleSessionError(String? reason, String? message, + {String? subReason}) async { + final userMessage = _getErrorMessage(reason, message, subReason: subReason); + final isStationaryRevoke = + reason == 'session_revoked' && subReason == 'stationary'; + + // Session ping-counter exhausted (wire tag's 11-bit cap). The session is still + // valid here, so flush the queue under it BEFORE disconnecting: clearOnDisconnect() + // drops pending pings, and token-ping wire tags would fail validation if re-uploaded + // later under a new session. + if (reason == 'session_limit') { + debugError('[SESSION] $userMessage'); + logError(userMessage, severity: ErrorSeverity.warning); + try { + await _apiQueueService.flushQueue(); + } catch (e) { + debugError( + '[SESSION] Queue flush before session-limit disconnect failed: $e'); + } + await disconnect(); + return; + } // Rate limiting should warn but not disconnect (per PORTED_APP behavior) if (reason == 'rate_limited') { - debugWarn('[API] Rate limited - continuing without disconnect: $userMessage'); + debugWarn( + '[API] Rate limited - continuing without disconnect: $userMessage'); return; } - // Log error + // Zone grace period: intercept outside_zone during active session + if (reason == 'outside_zone' && _isInZoneGracePeriod) { + debugLog( + '[ZONE GRACE] outside_zone during grace period — already handling'); + return; + } + if (reason == 'outside_zone' && isConnected && !_isInZoneGracePeriod) { + debugLog('[ZONE GRACE] outside_zone — entering grace period'); + await _startZoneGracePeriod(); + return; + } + + // Log error. A stationary revoke is the guard working as designed (a + // surveyor pacing one spot trips it), so it warns rather than alarms. debugError('[API] Session error: $reason - $userMessage'); - logError(userMessage, severity: ErrorSeverity.error); + logError(userMessage, + severity: + isStationaryRevoke ? ErrorSeverity.warning : ErrorSeverity.error); // Session errors that require disconnect const sessionErrors = { @@ -3268,11 +8158,46 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 'zone_disabled', }; - // Handle errors that require disconnect - if (sessionErrors.contains(reason) || - authErrors.contains(reason) || - zoneErrors.contains(reason)) { - debugLog('[API] Session error requires disconnect: $reason'); + // Handle errors that require disconnect + if (sessionErrors.contains(reason) || + authErrors.contains(reason) || + zoneErrors.contains(reason)) { + debugLog('[API] Session error requires disconnect: $reason'); + + // Preserve queued wardrive data to offline storage before disconnect clears it. + // NOT on a stationary revoke: those pings were earned sitting still, and + // preserving them just re-uploads them through the offline path later, + // which is exactly what the guard exists to stop. disconnect() below + // clears the queue, so skipping the snapshot is the drop. + if (isStationaryRevoke) { + debugLog( + '[SESSION] Stationary revoke: dropping ${_apiQueueService.queueSize} queued pings instead of preserving them'); + } else if (sessionErrors.contains(reason)) { + try { + // Land the per-repeater RX batches in the queue FIRST. disconnect() + // below flushes them too, but only after this snapshot has been + // taken and right before it clears the queue, so without this the + // best observation for every currently audible repeater is lost. + await _rxLogger?.flushAllBatches(trigger: 'session_expiry'); + + final queuedPings = await _apiQueueService.extractAllAsJson(); + if (queuedPings.isNotEmpty) { + final offlineDeviceName = _offlineDeviceName; + await _offlineSessionService.saveSession( + queuedPings, + devicePublicKey: _devicePublicKey, + deviceName: offlineDeviceName, + contactUri: _offlineContactUri, + radioConfig: _meshCoreConnection?.selfInfo?.radioConfigApi, + ); + debugLog( + '[APP] Preserved ${queuedPings.length} queued pings to offline storage on session expiry'); + } + } catch (e) { + debugError('[APP] Failed to preserve queue to offline storage: $e'); + } + } + // Don't call requestAuth disconnect - session is already invalid on server // Just cleanup locally and disconnect await disconnect(); @@ -3280,11 +8205,18 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Handle maintenance mode while connected - end session and log error - Future _handleMaintenanceModeConnected(String message, String? url) async { + Future _handleMaintenanceModeConnected( + String message, String? url) async { debugLog('[MAINTENANCE] Ending session due to maintenance mode'); + // Alert if auto-ping was running (maintenance is not user-initiated) + if (_autoPingEnabled) { + _playDisconnectAlert(); + } + // Log to error log (this sets _requestErrorLogSwitch = true) - logError('Maintenance Mode Enabled: $message', severity: ErrorSeverity.warning); + logError('Maintenance Mode Enabled: $message', + severity: ErrorSeverity.warning); // Disconnect (ends session, cleans up) await disconnect(); @@ -3315,7 +8247,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Start periodic polling to check if maintenance mode has ended void _startMaintenancePolling() { _maintenanceCheckTimer?.cancel(); - _maintenanceCheckTimer = Timer.periodic(const Duration(seconds: 30), (_) async { + _maintenanceCheckTimer = + Timer.periodic(const Duration(seconds: 30), (_) async { if (!_maintenanceMode) { _maintenanceCheckTimer?.cancel(); _maintenanceCheckTimer = null; @@ -3345,7 +8278,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Validate GPS position for API calls /// Returns (isValid, errorMessage, errorCode) tuple - ({bool isValid, String? errorMessage, String? errorCode}) _validateGps(Position? position) { + ({bool isValid, String? errorMessage, String? errorCode}) _validateGps( + Position? position) { if (position == null) { return ( isValid: false, @@ -3359,7 +8293,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (ageSeconds > _maxGpsAgeSeconds) { return ( isValid: false, - errorMessage: 'GPS data is ${ageSeconds}s old (max ${_maxGpsAgeSeconds}s)', + errorMessage: + 'GPS data is ${ageSeconds}s old (max ${_maxGpsAgeSeconds}s)', errorCode: 'gps_stale', ); } @@ -3368,213 +8303,957 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (position.accuracy > _maxGpsAccuracyMeters) { return ( isValid: false, - errorMessage: 'GPS accuracy is ${position.accuracy.toStringAsFixed(0)}m (max ${_maxGpsAccuracyMeters.toStringAsFixed(0)}m)', + errorMessage: + 'GPS accuracy is ${position.accuracy.toStringAsFixed(0)}m (max ${_maxGpsAccuracyMeters.toStringAsFixed(0)}m)', errorCode: 'gps_inaccurate', ); } - return (isValid: true, errorMessage: null, errorCode: null); - } + return (isValid: true, errorMessage: null, errorCode: null); + } + + /// Check if current GPS position is valid for API calls + bool get isGpsValidForApi { + final validation = _validateGps(_currentPosition); + return validation.isValid; + } + + // ============================================ + // Zone Status (Pre-Flight Checks) + // ============================================ + + /// App version for API calls (uses AppConstants.appVersion as single source of truth) + static String get _appVersion => AppConstants.appVersion; + + /// Zone check distance threshold (100 meters) + static const double _zoneCheckDistanceThreshold = 100.0; + + /// Check if zone status should be re-checked based on GPS movement + bool _shouldRecheckZone(Position position) { + if (_lastZoneCheckPosition == null) return true; + + final distance = Geolocator.distanceBetween( + _lastZoneCheckPosition!.latitude, + _lastZoneCheckPosition!.longitude, + position.latitude, + position.longitude, + ); + + return distance >= _zoneCheckDistanceThreshold; + } + + /// Schedule a zone check retry with countdown timer for UI feedback + void _scheduleZoneCheckRetry( + {required int seconds, required String error, required String reason}) { + // Cancel any existing timers + _zoneCheckRetryTimer?.cancel(); + _zoneCheckCountdownTimer?.cancel(); + + _zoneCheckError = error; + _zoneCheckErrorReason = reason; + _zoneCheckRetryCountdown = seconds; + notifyListeners(); + + // Single timer: ticks every 1s, retries at 0 + _zoneCheckCountdownTimer = Timer.periodic(const Duration(seconds: 1), (_) { + _zoneCheckRetryCountdown--; + notifyListeners(); + if (_zoneCheckRetryCountdown <= 0) { + // Cancel timers but keep error message visible during retry + _zoneCheckCountdownTimer?.cancel(); + _zoneCheckCountdownTimer = null; + _zoneCheckRetryTimer?.cancel(); + _zoneCheckRetryTimer = null; + checkZoneStatus(); + } + }); + } + + /// Clear zone check error state and cancel retry timers + void _clearZoneCheckError() { + _zoneCheckRetryTimer?.cancel(); + _zoneCheckCountdownTimer?.cancel(); + _zoneCheckRetryTimer = null; + _zoneCheckCountdownTimer = null; + _zoneCheckError = null; + _zoneCheckErrorReason = null; + _zoneCheckRetryCountdown = 0; + // Don't notifyListeners here — caller will do it or checkZoneStatus will + } + + /// Check zone status via API + /// Should be called on app launch and every 100m of GPS movement while disconnected + Future checkZoneStatus() async { + debugLog('[GEOFENCE] checkZoneStatus() called'); + debugLog( + '[GEOFENCE] Pre-check state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' + 'hasPosition=${_currentPosition != null}, gpsStatus=$_gpsStatus'); + + if (_currentPosition == null) { + debugLog( + '[GEOFENCE] Cannot check zone status: no GPS position (gpsStatus=$_gpsStatus)'); + return; + } + + if (_preferences.offlineMode) { + debugLog('[GEOFENCE] Skipping zone check: offline mode enabled'); + return; + } + + if (_isCheckingZone) { + debugLog( + '[GEOFENCE] Zone check already in progress, skipping duplicate call'); + return; + } + + debugLog( + '[GEOFENCE] Starting zone check - setting isCheckingZone=true (previous inZone=$_inZone)'); + _isCheckingZone = true; + // Don't clear error or notify here — keep current error view visible during retry + // to avoid a full-screen flash. Error is cleared in finally block on success, + // or overwritten by _scheduleZoneCheckRetry on failure. + + try { + debugLog( + '[GEOFENCE] Making API call to check zone at ${_currentPosition!.latitude.toStringAsFixed(5)}, ' + '${_currentPosition!.longitude.toStringAsFixed(5)} (accuracy: ${_currentPosition!.accuracy.toStringAsFixed(1)}m)'); + + final result = await _apiService.checkZoneStatus( + lat: _currentPosition!.latitude, + lon: _currentPosition!.longitude, + accuracyMeters: _currentPosition!.accuracy, + appVersion: _appVersion, + ); + + debugLog( + '[GEOFENCE] API response received: ${result != null ? 'valid' : 'null'}'); + + if (result == null) { + // Update position even on failure to prevent zone check flooding + // (without this, every GPS update re-triggers a zone check while driving) + _lastZoneCheckPosition = _currentPosition; + debugError('[GEOFENCE] Zone status check failed: no response from API'); + _scheduleZoneCheckRetry( + seconds: 5, + error: 'Verify your internet connection', + reason: 'network', + ); + return; + } + + // Got a real response — clear any previous retry error state + _clearZoneCheckError(); + + // Check for maintenance mode FIRST + if (result['maintenance'] == true) { + _maintenanceMode = true; + _maintenanceMessage = result['maintenance_message'] as String?; + _maintenanceUrl = result['maintenance_url'] as String?; + debugLog( + '[MAINTENANCE] Zone check returned maintenance: $_maintenanceMessage'); + + // Start polling to detect when maintenance ends + _startMaintenancePolling(); + + notifyListeners(); + return; // Don't process zone data + } + + // Clear maintenance if normal response + _clearMaintenanceMode(); + + _lastZoneCheckPosition = _currentPosition; + + final success = result['success'] == true; + if (!success) { + final reason = result['reason'] as String?; + final message = + result['message'] as String? ?? 'Zone status check failed'; + debugError( + '[GEOFENCE] Zone status check failed: reason=$reason, message=$message'); + + if (reason == 'gps_inaccurate') { + logError('GPS Accuracy Error\n$message', autoSwitch: false); + // Schedule a retry so we don't depend solely on the GPS stream firing + // again — on first launch the stream may stall on a low-accuracy fix + // and the coverage tile overlay would never load. + _scheduleZoneCheckRetry( + seconds: 10, error: message, reason: 'gps_inaccurate'); + } else if (reason == 'gps_stale') { + // The server rejected the position age, which means the phone clock + // disagrees with real time. Name the clock, not the GPS. + logError( + 'Phone Clock Out of Sync\nTurn on automatic date and time in ' + 'your phone settings.\n($message)', + autoSwitch: false); + _scheduleZoneCheckRetry( + seconds: 10, error: message, reason: 'gps_stale'); + } else if (reason == 'zone_disabled') { + final errorMsg = _getErrorMessage(reason, message); + logError(errorMsg); + _scheduleZoneCheckRetry( + seconds: 30, error: errorMsg, reason: reason!); + } else if (reason == 'bad_key' || reason == 'invalid_request') { + final errorMsg = _getErrorMessage(reason, message); + logError(errorMsg); + _scheduleZoneCheckRetry( + seconds: 60, error: errorMsg, reason: reason!); + } else { + // Unknown server errors — use server message + _scheduleZoneCheckRetry( + seconds: 15, error: message, reason: 'server_error'); + } + + return; + } + + _inZone = result['in_zone'] == true; + + if (_inZone!) { + final newZone = result['zone'] as Map?; + final newZoneCode = newZone?['code'] as String? ?? ''; + final newZoneName = newZone?['name'] ?? 'Unknown'; + + // Detect zone-to-zone transition during active session + if (isConnected && + !_preferences.offlineMode && + _sessionZoneCode != null && + newZoneCode.isNotEmpty && + newZoneCode != _sessionZoneCode && + !_isInZoneGracePeriod && + !_isZoneTransferInProgress) { + _currentZone = newZone; + _nearestZone = null; + await _handleZoneTransfer(newZoneCode, newZoneName); + return; + } + + _currentZone = newZone; + _nearestZone = null; + + final staleHours = result['stale_repeater_hours']; + if (staleHours is int && staleHours > 0) { + if (Repeater.staleHoursFallback != staleHours) { + Repeater.staleHoursFallback = staleHours; + _siriRepeaterCatalogRevision++; + } + debugLog('[GEOFENCE] Zone stale repeater threshold: ${staleHours}h'); + } + + debugLog('[GEOFENCE] In zone: $newZoneName ($newZoneCode)'); + + if (newZoneCode.isNotEmpty) { + _fetchRepeatersForZone( + newZoneCode); // fire-and-forget — don't block zone check + _fetchBorderPolygons(newZoneCode); // fire-and-forget + } + } else { + _regionBorders = []; + _bordersLoadedForZone = null; + _currentZone = null; + _nearestZone = result['nearest_zone'] as Map?; + final nearestName = _nearestZone?['name'] ?? 'Unknown'; + final distanceKm = + (_nearestZone?['distance_km'] as num?)?.toStringAsFixed(1) ?? '?'; + debugWarn( + '[GEOFENCE] Outside zone. Nearest: $nearestName (${distanceKm}km away)'); + + // Clear repeaters when exiting zone + _repeaters = []; + _siriRepeaterCatalogRevision++; + _repeatersLoaded = false; + _repeatersLoadedForIata = null; + } + } catch (e) { + debugError('[GEOFENCE] Zone status check error: $e'); + } finally { + _isCheckingZone = false; + debugLog( + '[GEOFENCE] Zone check complete - final state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' + 'zoneName=${_currentZone?['name']}, zoneCode=${_currentZone?['code']}'); + notifyListeners(); + } + } + + /// Sync zone capacity display with auth result. + /// The /status API (pre-connection) and /auth API (during connection) can + /// return different capacity views. This keeps the connection screen's slot + /// display consistent with the map tab's txAllowed flag. + void _syncZoneCapacityFromAuth(Map authResult) { + if (_currentZone == null) return; + + // If auth response includes slot data, use it directly (forward-compatible) + if (authResult.containsKey('slots_available')) { + _currentZone!['slots_available'] = authResult['slots_available']; + debugLog( + '[CAPACITY] Updated slots_available from auth: ${authResult['slots_available']}'); + } + if (authResult.containsKey('slots_max')) { + _currentZone!['slots_max'] = authResult['slots_max']; + debugLog( + '[CAPACITY] Updated slots_max from auth: ${authResult['slots_max']}'); + } + + // Sync at_capacity with tx_allowed + final authTxAllowed = authResult['tx_allowed'] == true; + _currentZone!['at_capacity'] = !authTxAllowed; + + // If auth says TX not allowed and server didn't provide slot data, set slots to 0 + if (!authTxAllowed && !authResult.containsKey('slots_available')) { + _currentZone!['slots_available'] = 0; + debugLog( + '[CAPACITY] Zone at TX capacity per auth, set slots_available=0'); + } + + // If auth says TX allowed and we have slot data but server didn't provide updated count, + // decrement by 1 (we just took a slot) + if (authTxAllowed && !authResult.containsKey('slots_available')) { + final available = _currentZone!['slots_available'] as int?; + if (available != null && available > 0) { + _currentZone!['slots_available'] = available - 1; + debugLog('[CAPACITY] Took a slot, slots_available=${available - 1}'); + } + } + + notifyListeners(); + } + + /// Start periodic zone status refresh while connected. + /// Keeps slot counts and capacity status fresh during a session. + void _startZoneRefreshTimer() { + _zoneRefreshTimer?.cancel(); + _zoneRefreshTimer = Timer.periodic(const Duration(seconds: 60), (_) async { + if (!isConnected || _preferences.offlineMode) { + _zoneRefreshTimer?.cancel(); + _zoneRefreshTimer = null; + return; + } + debugLog('[CAPACITY] Periodic zone refresh'); + await checkZoneStatus(); + }); + debugLog('[CAPACITY] Started 60s zone refresh timer'); + } + + /// Stop zone status refresh timer. + void _stopZoneRefreshTimer() { + _zoneRefreshTimer?.cancel(); + _zoneRefreshTimer = null; + } + + // ============================================ + // Zone Grace Period + // ============================================ + + /// Cancel all zone grace period timers. + void _cancelZoneGraceTimers() { + _zoneGraceEndsAt = null; + _zoneGraceTimer?.cancel(); + _zoneGraceTimer = null; + _zoneGracePollingTimer?.cancel(); + _zoneGracePollingTimer = null; + _zoneGraceCountdownTimer?.cancel(); + _zoneGraceCountdownTimer = null; + } + + /// Enter zone grace period when outside_zone is detected during an active session. + /// Pauses wardriving but keeps BLE and API session alive. + /// Polls for zone re-entry every 5s; auto-disconnects after 5 minutes. + Future _startZoneGracePeriod() async { + if (_isInZoneGracePeriod) return; + _isInZoneGracePeriod = true; + debugLog( + '[ZONE GRACE] Entering zone grace period (${_zoneGraceTimeout.inMinutes}m timeout)'); + logError('Left wardriving zone. Searching for nearby zone...', + severity: ErrorSeverity.warning, autoSwitch: false); + + // Save auto-ping state for restoration on zone re-entry + _autoPingWasEnabledBeforeGrace = _autoPingEnabled; + _autoModeBeforeGrace = _autoMode; + + // Stop auto-ping timers and disable + _autoPingTimer.stop(); + _rxWindowTimer.stop(); + _cooldownTimer.stop(); + if (_autoPingEnabled) { + _autoPingEnabled = false; + _resetIdleAutoStop(); + await _pingService?.forceDisableAutoPing(); + debugLog('[ZONE GRACE] Auto-ping paused'); + } + + // Disable heartbeat (no point while outside zone) + _apiService.disableHeartbeat(); + + // Stop RX logger (no session context for RX data) + _rxLogger?.stopWardriving(trigger: 'zone_grace'); + + // Stop zone refresh timer (replaced by 5s grace polling) + _stopZoneRefreshTimer(); + + // Cancel idle disconnect timer + _cancelIdleDisconnectTimer(); + + // Stop background service + await BackgroundServiceManager.stopService(); + + // Clear API queue — items have gap-GPS coords that would be rejected again + await _apiQueueService.clearOnDisconnect(); + + // Keep alive: BLE, _meshCoreConnection, _pingService, _unifiedRxHandler, + // noise floor, and API session (backend auto-transfers on zone re-entry) + + // Start 5-minute countdown. Keep an absolute deadline so ActivityKit can + // render the timer without receiving an update every second. + _zoneGraceEndsAt = DateTime.now().add(_zoneGraceTimeout); + _zoneGraceSecondsRemaining = _zoneGraceTimeout.inSeconds; + + // Overall timeout — abandon grace period after 5 minutes + _zoneGraceTimer = Timer(_zoneGraceTimeout, () { + debugLog('[ZONE GRACE] Timeout expired — abandoning'); + _abandonZoneGracePeriod(); + }); + + // 1-second countdown tick for UI + _zoneGraceCountdownTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (_zoneGraceSecondsRemaining > 0) { + _zoneGraceSecondsRemaining--; + notifyListeners(); + } + }); + + // Trigger immediate zone check, then start 5-second polling + _pollZoneDuringGracePeriod(); + _zoneGracePollingTimer = Timer.periodic(const Duration(seconds: 5), (_) { + _pollZoneDuringGracePeriod(); + }); + + notifyListeners(); + } + + /// Poll zone status during grace period (called every 5s). + Future _pollZoneDuringGracePeriod() async { + if (!_isInZoneGracePeriod) { + _zoneGracePollingTimer?.cancel(); + _zoneGracePollingTimer = null; + return; + } + + debugLog('[ZONE GRACE] Polling zone status...'); + try { + await checkZoneStatus(); + } catch (e) { + debugWarn('[ZONE GRACE] Zone check failed: $e'); + return; // Retry on next tick + } + + // checkZoneStatus updates _inZone and calls notifyListeners (overlay auto-updates) + if (_inZone == true) { + final reEnteredZoneCode = _currentZone?['code'] as String? ?? ''; + debugLog( + '[ZONE GRACE] Zone re-entered: ${_currentZone?['name']} ($reEnteredZoneCode)'); + + // If re-entering a DIFFERENT zone, do a full zone transfer instead of simple resume + if (_sessionZoneCode != null && + reEnteredZoneCode.isNotEmpty && + reEnteredZoneCode != _sessionZoneCode) { + debugLog( + '[ZONE GRACE] Re-entered different zone ($reEnteredZoneCode vs session $_sessionZoneCode) — transferring'); + _cancelZoneGraceTimers(); + _isInZoneGracePeriod = false; + _zoneGraceSecondsRemaining = 0; + final savedAutoPing = _autoPingWasEnabledBeforeGrace; + final savedMode = _autoModeBeforeGrace; + _autoPingWasEnabledBeforeGrace = false; + await _handleZoneTransfer( + reEnteredZoneCode, _currentZone?['name'] ?? 'Unknown', + wasAutoPingOverride: savedAutoPing, + previousModeOverride: savedMode); + return; + } - /// Check if current GPS position is valid for API calls - bool get isGpsValidForApi { - final validation = _validateGps(_currentPosition); - return validation.isValid; + await _onZoneGraceReEntry(); + } } - // ============================================ - // Zone Status (Pre-Flight Checks) - // ============================================ + /// Zone re-entered during grace period — resume wardriving. + /// Session is preserved; backend auto-transfers to the new zone. + Future _onZoneGraceReEntry() async { + _cancelZoneGraceTimers(); - /// App version for API calls (uses AppConstants.appVersion as single source of truth) - static String get _appVersion => AppConstants.appVersion; + final wasAutoPing = _autoPingWasEnabledBeforeGrace; + final previousMode = _autoModeBeforeGrace; - /// Zone check distance threshold (100 meters) - static const double _zoneCheckDistanceThreshold = 100.0; + // Clear grace state + _isInZoneGracePeriod = false; + _zoneGraceSecondsRemaining = 0; + _autoPingWasEnabledBeforeGrace = false; - /// Check if zone status should be re-checked based on GPS movement - bool _shouldRecheckZone(Position position) { - if (_lastZoneCheckPosition == null) return true; + debugLog( + '[ZONE GRACE] Resuming wardriving (autoPing=$wasAutoPing, mode=$previousMode)'); + logError('Re-entered wardriving zone. Resuming...', + severity: ErrorSeverity.info, autoSwitch: false); - final distance = Geolocator.distanceBetween( - _lastZoneCheckPosition!.latitude, - _lastZoneCheckPosition!.longitude, - position.latitude, - position.longitude, + // Re-enable heartbeat + _apiService.enableHeartbeat( + gpsProvider: () { + final pos = _gpsService.lastPosition; + if (pos == null) return null; + return (lat: pos.latitude, lon: pos.longitude); + }, ); - return distance >= _zoneCheckDistanceThreshold; - } + // Restart zone refresh timer (60s) + _startZoneRefreshTimer(); - /// Schedule a zone check retry with countdown timer for UI feedback - void _scheduleZoneCheckRetry({required int seconds, required String error, required String reason}) { - // Cancel any existing timers - _zoneCheckRetryTimer?.cancel(); - _zoneCheckCountdownTimer?.cancel(); + // Prepare API queue for fresh data + await _apiQueueService.clearBeforeConnect(); + + // Restore auto-ping if it was active + if (wasAutoPing) { + _restoreAutoPingTimer?.cancel(); + _restoreAutoPingTimer = Timer(const Duration(milliseconds: 500), () { + _restoreAutoPingTimer = null; + if (_isDisposed || + _userRequestedDisconnect || + _connectionStep != ConnectionStep.connected || + _pingService == null) { + debugLog( + '[ZONE GRACE] Skipping auto-ping restore (stale or disconnected state)'); + return; + } + if (!_autoPingEnabled) { + _cooldownTimer.stop(); + _pingService!.clearCooldown(); + final resolvedMode = _resolveAutoModeForZone(previousMode); + debugLog('[ZONE GRACE] Mode resolved: $previousMode → $resolvedMode'); + toggleAutoPing(resolvedMode); + } + }); + } else { + _startIdleDisconnectTimer(); + } - _zoneCheckError = error; - _zoneCheckErrorReason = reason; - _zoneCheckRetryCountdown = seconds; notifyListeners(); + } - // Single timer: ticks every 1s, retries at 0 - _zoneCheckCountdownTimer = Timer.periodic(const Duration(seconds: 1), (_) { - _zoneCheckRetryCountdown--; - notifyListeners(); - if (_zoneCheckRetryCountdown <= 0) { - // Cancel timers but keep error message visible during retry - _zoneCheckCountdownTimer?.cancel(); - _zoneCheckCountdownTimer = null; - _zoneCheckRetryTimer?.cancel(); - _zoneCheckRetryTimer = null; - checkZoneStatus(); - } - }); + /// Abandon zone grace period — timeout, failure, or BLE disconnect. + Future _abandonZoneGracePeriod() async { + _cancelZoneGraceTimers(); + + if (_autoPingWasEnabledBeforeGrace) { + _playDisconnectAlert(); + } + + // Clear grace state + _isInZoneGracePeriod = false; + _zoneGraceSecondsRemaining = 0; + _autoPingWasEnabledBeforeGrace = false; + + debugLog('[ZONE GRACE] Abandoned — performing full disconnect'); + + // Full disconnect cleanup + await disconnect(); } - /// Clear zone check error state and cancel retry timers - void _clearZoneCheckError() { - _zoneCheckRetryTimer?.cancel(); - _zoneCheckCountdownTimer?.cancel(); - _zoneCheckRetryTimer = null; - _zoneCheckCountdownTimer = null; - _zoneCheckError = null; - _zoneCheckErrorReason = null; - _zoneCheckRetryCountdown = 0; - // Don't notifyListeners here — caller will do it or checkZoneStatus will + /// Cancel zone grace period (user-triggered from UI cancel button). + Future cancelZoneGracePeriod() async { + debugLog('[ZONE GRACE] Cancelled by user'); + await _abandonZoneGracePeriod(); } - /// Check zone status via API - /// Should be called on app launch and every 100m of GPS movement while disconnected - Future checkZoneStatus() async { - debugLog('[GEOFENCE] checkZoneStatus() called'); - debugLog('[GEOFENCE] Pre-check state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' - 'hasPosition=${_currentPosition != null}, gpsStatus=$_gpsStatus'); + /// Resolve the desired auto-ping mode against the current zone's permissions. + /// Maps invalid modes to the best valid alternative for the new zone. + AutoMode _resolveAutoModeForZone(AutoMode desired) { + final txOk = _apiService.txAllowed; + final rxOk = _apiService.rxAllowed; + final hybrid = _apiService.enforceHybrid; - if (_currentPosition == null) { - debugLog('[GEOFENCE] Cannot check zone status: no GPS position (gpsStatus=$_gpsStatus)'); - return; + // No TX allowed → passive only + if (!txOk && rxOk) return AutoMode.passive; + + // TX allowed but zone enforces hybrid → map active to hybrid + if (txOk && hybrid && desired == AutoMode.active) return AutoMode.hybrid; + + // TX allowed, zone doesn't enforce hybrid → map hybrid back to active + // (unless user explicitly chose hybrid via _userOriginalHybridMode) + if (txOk && !hybrid && desired == AutoMode.hybrid) { + if (_userOriginalHybridMode != true) return AutoMode.active; } - if (_isCheckingZone) { - debugLog('[GEOFENCE] Zone check already in progress, skipping duplicate call'); + // Targeted/trace mode is transport-level, not channel TX — keep as-is + return desired; + } + + // ============================================ + // Zone-to-Zone Transfer + // ============================================ + + /// Handle zone-to-zone transfer during active wardriving session. + /// Releases old zone session and acquires new session for target zone. + /// Preserves BLE connection and radio configuration. + Future _handleZoneTransfer(String newZoneCode, String newZoneName, + {bool? wasAutoPingOverride, AutoMode? previousModeOverride}) async { + if (_isZoneTransferInProgress) { + debugLog('[ZONE] Transfer already in progress, skipping'); return; } - debugLog('[GEOFENCE] Starting zone check - setting isCheckingZone=true (previous inZone=$_inZone)'); - _isCheckingZone = true; - // Don't clear error or notify here — keep current error view visible during retry - // to avoid a full-screen flash. Error is cleared in finally block on success, - // or overwritten by _scheduleZoneCheckRetry on failure. + final oldZoneCode = _sessionZoneCode ?? 'unknown'; + _isZoneTransferInProgress = true; + _zoneTransferFrom = oldZoneCode; + _zoneTransferTo = newZoneCode; + debugLog('[ZONE] Starting zone transfer: $oldZoneCode → $newZoneCode'); + notifyListeners(); try { - debugLog('[GEOFENCE] Making API call to check zone at ${_currentPosition!.latitude.toStringAsFixed(5)}, ' - '${_currentPosition!.longitude.toStringAsFixed(5)} (accuracy: ${_currentPosition!.accuracy.toStringAsFixed(1)}m)'); + // 1. Save auto-ping state for restoration + // Prefer overrides from grace period (where provider state was already cleared) + final wasAutoPing = wasAutoPingOverride ?? _autoPingEnabled; + final previousMode = previousModeOverride ?? _autoMode; - final result = await _apiService.checkZoneStatus( + // 2. Pause auto-ping and wardriving activity + _autoPingTimer.stop(); + _rxWindowTimer.stop(); + _cooldownTimer.stop(); + if (_autoPingEnabled) { + _autoPingEnabled = false; + _resetIdleAutoStop(); + await _pingService?.forceDisableAutoPing(); + debugLog('[ZONE] Auto-ping paused for zone transfer'); + } + + // 3. Disable heartbeat (old session is about to be released) + _apiService.disableHeartbeat(); + + // 4. Stop RX logger (no valid session context during transfer) + _rxLogger?.stopWardriving(trigger: 'zone_transfer'); + + // 5. Stop zone refresh timer (we're handling the zone change now) + _stopZoneRefreshTimer(); + + // 6. Cancel idle disconnect timer + _cancelIdleDisconnectTimer(); + + // 7. Clear API queue (items were created for old zone's session) + await _apiQueueService.clearOnDisconnect(); + + // 8. Release old session (best effort) + if (_devicePublicKey != null && _apiService.hasSession) { + debugLog('[ZONE] Releasing old session for zone $oldZoneCode'); + try { + await _apiService.requestAuth( + reason: 'disconnect', + publicKey: _devicePublicKey!, + ); + debugLog('[ZONE] Old session released'); + } catch (e) { + debugError('[ZONE] Failed to release old session: $e'); + } + } + + // 9. Acquire new session for target zone + final deviceName = _isAnonymousRenamed + ? 'Anonymous' + : (_meshCoreConnection?.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); + + if (_devicePublicKey == null || + deviceName == null || + _currentPosition == null) { + debugError('[ZONE] Cannot transfer: missing device key, name, or GPS'); + await disconnect(); + return; + } + + debugLog('[ZONE] Requesting auth for zone $newZoneCode'); + final modelString = _meshCoreConnection?.deviceModel?.manufacturer ?? + _meshCoreConnection?.deviceInfo?.manufacturer ?? + 'Unknown'; + var result = await _apiService.requestAuth( + reason: 'connect', + publicKey: _devicePublicKey!, + who: deviceName, + appVersion: _appVersion, + power: _preferences.powerLevel, + iataCode: newZoneCode, + model: modelString, + radioFreq: _meshCoreConnection?.selfInfo?.radioConfigApi, lat: _currentPosition!.latitude, lon: _currentPosition!.longitude, accuracyMeters: _currentPosition!.accuracy, - appVersion: _appVersion, ); - debugLog('[GEOFENCE] API response received: ${result != null ? 'valid' : 'null'}'); - + // 10. Check auth result if (result == null) { - debugError('[GEOFENCE] Zone status check failed: no response from API'); - _scheduleZoneCheckRetry( - seconds: 5, - error: 'Verify your internet connection', - reason: 'network', - ); + debugError('[ZONE] Auth failed for zone $newZoneCode: network error'); + logError('Zone transfer failed: unable to reach server', + severity: ErrorSeverity.error); + await disconnect(); return; } - // Got a real response — clear any previous retry error state - _clearZoneCheckError(); - - // Check for maintenance mode FIRST if (result['maintenance'] == true) { _maintenanceMode = true; _maintenanceMessage = result['maintenance_message'] as String?; _maintenanceUrl = result['maintenance_url'] as String?; - debugLog('[MAINTENANCE] Zone check returned maintenance: $_maintenanceMessage'); - - // Start polling to detect when maintenance ends _startMaintenancePolling(); - notifyListeners(); - return; // Don't process zone data + await disconnect(); + return; } - // Clear maintenance if normal response - _clearMaintenanceMode(); + if (result['success'] != true) { + final reason = result['reason'] as String? ?? 'unknown'; + final message = result['message'] as String? ?? 'Auth failed'; + debugError( + '[ZONE] Auth failed for zone $newZoneCode: $reason - $message'); - _lastZoneCheckPosition = _currentPosition; + // Stage 2: unknown_device → register via signed contact URI (mirrors initial connect) + if (reason == 'unknown_device' && _meshCoreConnection != null) { + debugLog( + '[ZONE] Stage 2: Attempting registration via contact_uri for zone $newZoneCode'); - final success = result['success'] == true; - if (!success) { - final reason = result['reason'] as String?; - final message = result['message'] as String? ?? 'Zone status check failed'; - debugError('[GEOFENCE] Zone status check failed: reason=$reason, message=$message'); + String? contactUri; + try { + contactUri = await _meshCoreConnection!.exportContact(); + debugLog( + '[ZONE] Received contact URI: ${contactUri.substring(0, contactUri.length < 50 ? contactUri.length : 50)}...'); + } catch (e) { + debugError('[ZONE] Stage 2 failed: could not export contact: $e'); + logError('Zone transfer failed: $message', + severity: ErrorSeverity.error); + await disconnect(); + return; + } - if (reason == 'gps_inaccurate') { - logError('GPS Accuracy Error\n$message'); - _scheduleZoneCheckRetry(seconds: 5, error: message, reason: 'gps_inaccurate'); - } else if (reason == 'gps_stale') { - logError('GPS Stale Error\n$message'); - _scheduleZoneCheckRetry(seconds: 5, error: message, reason: 'gps_stale'); - } else if (reason == 'zone_disabled') { - final errorMsg = _getErrorMessage(reason, message); - logError(errorMsg); - _scheduleZoneCheckRetry(seconds: 30, error: errorMsg, reason: reason!); - } else if (reason == 'bad_key' || reason == 'invalid_request') { - final errorMsg = _getErrorMessage(reason, message); - logError(errorMsg); - _scheduleZoneCheckRetry(seconds: 60, error: errorMsg, reason: reason!); + final registerResult = await _apiService.requestAuth( + reason: 'register', + contactUri: contactUri, + who: deviceName, + appVersion: _appVersion, + power: _preferences.powerLevel, + iataCode: newZoneCode, + model: modelString, + radioFreq: _meshCoreConnection?.selfInfo?.radioConfigApi, + lat: _currentPosition!.latitude, + lon: _currentPosition!.longitude, + accuracyMeters: _currentPosition!.accuracy, + ); + + if (registerResult == null) { + debugError('[ZONE] Stage 2 failed: network error'); + logError('Zone transfer failed: unable to register with server', + severity: ErrorSeverity.error); + await disconnect(); + return; + } + + if (registerResult['success'] != true) { + final regReason = + registerResult['reason'] as String? ?? 'registration_failed'; + final regMessage = registerResult['message'] as String? ?? + 'Registration rejected by server'; + debugError('[ZONE] Stage 2 failed: $regReason - $regMessage'); + logError('Zone transfer failed: $regMessage', + severity: ErrorSeverity.error); + await disconnect(); + return; + } + + debugLog( + '[ZONE] Stage 2 succeeded: registered for zone $newZoneCode'); + result = registerResult; } else { - // Unknown server errors — use server message - _scheduleZoneCheckRetry(seconds: 15, error: message, reason: 'server_error'); + logError('Zone transfer failed: $message', + severity: ErrorSeverity.error); + await disconnect(); + return; } + } - return; + // 11. Auth succeeded — update session zone code + _sessionZoneCode = newZoneCode; + debugLog('[ZONE] Auth succeeded for zone $newZoneCode'); + + if (result['type'] != null) { + _authType = result['type'] as String; } - _inZone = result['in_zone'] == true; + _syncZoneCapacityFromAuth(result); - if (_inZone!) { - _currentZone = result['zone'] as Map?; - _nearestZone = null; - final zoneName = _currentZone?['name'] ?? 'Unknown'; - final zoneCode = _currentZone?['code'] as String? ?? ''; - debugLog('[GEOFENCE] In zone: $zoneName ($zoneCode)'); + // 12. Update regional channels from new auth response + final apiChannels = _apiService.channels; + await ChannelService.setRegionalChannels(apiChannels); + _regionalChannels = ChannelService.getRegionalChannelNames(); + debugLog('[ZONE] Regional channels updated: $_regionalChannels'); - // Fetch repeaters for this zone - if (zoneCode.isNotEmpty) { - await _fetchRepeatersForZone(zoneCode); + // 13. Update PacketValidator with new channel configuration + if (_unifiedRxHandler != null) { + final allowedChannelsData = + ChannelService.getAllowedChannelsForValidator(); + final allowedChannels = {}; + for (final entry in allowedChannelsData.entries) { + allowedChannels[entry.key] = ChannelInfo( + channelName: entry.value.channelName, + key: entry.value.key, + hash: entry.value.hash, + ); } + final newValidator = PacketValidator( + allowedChannels: allowedChannels, + disableRssiFilter: _preferences.disableRssiFilter, + ); + _unifiedRxHandler!.updateValidator(newValidator); + debugLog( + '[ZONE] PacketValidator updated with ${allowedChannels.length} channels'); + } + + // 14. Update flood scope from new auth response + final apiScopes = _apiService.scopes; + final firstScope = apiScopes.isNotEmpty ? apiScopes.first : null; + final isWildcard = + firstScope == null || firstScope == '*' || firstScope == '#*'; + if (!isWildcard) { + final scopeName = firstScope; + _scope = scopeName.startsWith('#') ? scopeName : '#$scopeName'; + final scopeKey = CryptoService.deriveScopeKey(scopeName); + debugLog('[ZONE] Setting flood scope: $scopeName'); + await _meshCoreConnection!.setFloodScope(scopeKey); } else { - _currentZone = null; - _nearestZone = result['nearest_zone'] as Map?; - final nearestName = _nearestZone?['name'] ?? 'Unknown'; - final distanceKm = (_nearestZone?['distance_km'] as num?)?.toStringAsFixed(1) ?? '?'; - debugWarn('[GEOFENCE] Outside zone. Nearest: $nearestName (${distanceKm}km away)'); + if (_scope != null) { + try { + await _meshCoreConnection?.clearFloodScope(); + } catch (e) { + debugLog('[ZONE] Failed to clear flood scope: $e'); + } + } + _scope = null; + debugLog('[ZONE] No regional scope — using unscoped flood'); + } - // Clear repeaters when exiting zone - _repeaters = []; - _repeatersLoaded = false; - _repeatersLoadedForIata = null; + // 15. Restore user's original preferences, then apply new zone's policies + if (_userOriginalAutoPingInterval != null) { + _preferences = _preferences.copyWith( + autoPingInterval: _userOriginalAutoPingInterval!); + } + if (_userOriginalHybridMode != null) { + _preferences = + _preferences.copyWith(hybridModeEnabled: _userOriginalHybridMode!); + } + if (_userOriginalDiscDrop != null) { + _preferences = + _preferences.copyWith(discDropEnabled: _userOriginalDiscDrop!); + } + if (_userOriginalFloodTraffic != null) { + _preferences = _preferences.copyWith( + floodTrafficEnabled: _userOriginalFloodTraffic!); + } + debugLog( + '[ZONE] Preferences restored to user baseline before applying new zone policies'); + + if (_apiService.enforceHybrid && !_preferences.hybridModeEnabled) { + _preferences = _preferences.copyWith(hybridModeEnabled: true); + debugLog('[ZONE] Hybrid mode force-enabled by new zone admin'); + } + if (_apiService.enforceDiscDrop && !_preferences.discDropEnabled) { + _preferences = _preferences.copyWith(discDropEnabled: true); + debugLog('[ZONE] Discovery drop force-enabled by new zone admin'); + } + final shouldEnableFlood = !_apiService.floodDisabled; + if (_preferences.floodTrafficEnabled != shouldEnableFlood) { + _preferences = + _preferences.copyWith(floodTrafficEnabled: shouldEnableFlood); + debugLog(shouldEnableFlood + ? '[ZONE] Flood traffic auto-enabled (new zone permits)' + : '[ZONE] Flood traffic disabled by new zone admin'); + } + if (_preferences.autoPingInterval < _apiService.minModeInterval) { + _preferences = _preferences.copyWith( + autoPingInterval: _apiService.minModeInterval); + debugLog( + '[ZONE] Auto-ping interval bumped to ${_apiService.minModeInterval}s by new zone admin'); + } + + // 16. Reconfigure path hash mode if new zone requires different hop bytes + await _configurePathHashMode(); + if (_pingService != null) { + _pingService!.hopBytes = effectiveHopBytes; + _pingService!.traceHopBytes = _traceHopBytes; + } + + // 17. Fetch repeaters for the new zone + _repeatersLoaded = false; + _repeatersLoadedForIata = null; + await _fetchRepeatersForZone(newZoneCode); + + // Fetch updated boundary polygons for the new zone + _bordersLoadedForZone = null; + _regionBorders = []; + _fetchBorderPolygons(newZoneCode); // fire-and-forget + + // 18. Re-enable heartbeat + _apiService.enableHeartbeat( + gpsProvider: () { + final pos = _gpsService.lastPosition; + if (pos == null) return null; + return (lat: pos.latitude, lon: pos.longitude); + }, + ); + + // 19. Restart zone refresh timer + _startZoneRefreshTimer(); + + // 20. Prepare API queue for fresh data in new zone + await _apiQueueService.clearBeforeConnect(); + + // 21. Restore auto-ping if it was active + if (wasAutoPing) { + _restoreAutoPingTimer?.cancel(); + _restoreAutoPingTimer = Timer(const Duration(milliseconds: 500), () { + _restoreAutoPingTimer = null; + if (_isDisposed || + _userRequestedDisconnect || + _connectionStep != ConnectionStep.connected || + _pingService == null) { + debugLog( + '[ZONE] Skipping auto-ping restore (stale or disconnected state)'); + return; + } + if (!_autoPingEnabled) { + _cooldownTimer.stop(); + _pingService!.clearCooldown(); + final resolvedMode = _resolveAutoModeForZone(previousMode); + debugLog( + '[ZONE] Mode resolved for new zone: $previousMode → $resolvedMode'); + toggleAutoPing(resolvedMode); + } + }); + } else { + _startIdleDisconnectTimer(); } + + debugLog('[ZONE] Zone transfer complete: $oldZoneCode → $newZoneCode'); } catch (e) { - debugError('[GEOFENCE] Zone status check error: $e'); + debugError('[ZONE] Zone transfer error: $e'); + logError('Zone transfer failed: $e', severity: ErrorSeverity.error); + await disconnect(); } finally { - _isCheckingZone = false; - debugLog('[GEOFENCE] Zone check complete - final state: inZone=$_inZone, isCheckingZone=$_isCheckingZone, ' - 'zoneName=${_currentZone?['name']}, zoneCode=${_currentZone?['code']}'); + _isZoneTransferInProgress = false; + _zoneTransferFrom = null; + _zoneTransferTo = null; notifyListeners(); } } + /// Cancel zone transfer (user-triggered from UI cancel button). + Future cancelZoneTransfer() async { + debugLog('[ZONE] Zone transfer cancelled by user'); + _isZoneTransferInProgress = false; + _zoneTransferFrom = null; + _zoneTransferTo = null; + await disconnect(); + } + /// Fetch repeaters for a zone (called when zone is discovered) /// Only fetches once per IATA code to avoid redundant network requests Future _fetchRepeatersForZone(String iata) async { @@ -3587,16 +9266,51 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[MAP] Fetching repeaters for zone: $iata'); try { final fetchedRepeaters = await _apiService.fetchRepeaters(iata); - _repeaters = fetchedRepeaters; - _repeatersLoaded = true; - _repeatersLoadedForIata = iata; - debugLog('[MAP] Loaded ${_repeaters.length} repeaters for zone $iata'); - notifyListeners(); + if (fetchedRepeaters.isNotEmpty) { + _repeaters = fetchedRepeaters; + _siriRepeaterCatalogRevision++; + _repeatersLoaded = true; + _repeatersLoadedForIata = iata; + debugLog('[MAP] Loaded ${_repeaters.length} repeaters for zone $iata'); + _notifyMapNow(); + } else { + debugWarn( + '[MAP] No repeaters returned for zone $iata — will retry on next zone check'); + } } catch (e) { debugError('[MAP] Failed to fetch repeaters: $e'); } } + /// Fetch regional boundary polygons for the current zone. + /// Called after a successful zone check; idempotent per IATA so the + /// /border endpoint is only hit once per zone transition. + Future _fetchBorderPolygons(String iata) async { + if (_bordersLoadedForZone == iata) return; + if (_bordersFetchInProgress) return; + if (_currentPosition == null) return; + + _bordersFetchInProgress = true; + try { + final result = await _apiService.fetchBorderPolygons( + lat: _currentPosition!.latitude, + lon: _currentPosition!.longitude, + appVersion: _appVersion, + ); + if (result != null && result.isNotEmpty) { + _regionBorders = result; + _bordersLoadedForZone = iata; + debugLog('[BORDER] Loaded ${result.length} polygon(s) for $iata'); + notifyListeners(); + } else { + debugWarn( + '[BORDER] No polygons returned for zone $iata — will retry on next zone check'); + } + } finally { + _bordersFetchInProgress = false; + } + } + // ============================================ // Debug File Logging (Mobile Only) // ============================================ @@ -3751,6 +9465,28 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + /// Export an offline session to a file and open the native share sheet (Save + /// to Files, Drive, email, …) — the mobile equivalent of the web JSON + /// download. Mirrors [shareDebugLog]; writes the same pretty JSON the web + /// path produces. Throws on failure so the caller can surface an error. + Future shareOfflineSession(String filename) async { + final data = _offlineSessionService.getSessionData(filename); + if (data == null) { + throw Exception('Session "$filename" not found'); + } + final jsonString = const JsonEncoder.withIndent(' ').convert(data); + final dir = await getTemporaryDirectory(); + final file = File('${dir.path}/$filename'); + await file.writeAsString(jsonString); + final result = await SharePlus.instance.share( + ShareParams( + files: [XFile(file.path)], + subject: 'MeshMapper Offline Session', + ), + ); + debugLog('[OFFLINE] Shared session: $filename, status: ${result.status}'); + } + /// View a debug log file in-app /// /// Reads the file contents and stores them for display in a dialog. @@ -3841,7 +9577,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Load a route file (KML or GPX) bool loadSimulatorRoute(String content, {String? filename}) { - final success = _gpsService.simulator.loadRoute(content, filename: filename); + final success = + _gpsService.simulator.loadRoute(content, filename: filename); if (success) { _gpsSimulatorPattern = SimulatorPattern.route; // If simulator is running, it will automatically use the new route @@ -3900,7 +9637,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Attempt to recover from Hive corruption - Future?> _attemptHiveRecovery(String boxName, Duration timeout) async { + Future?> _attemptHiveRecovery( + String boxName, Duration timeout) async { try { debugLog('[HIVE] Deleting corrupted box "$boxName"...'); await Hive.deleteBoxFromDisk(boxName); @@ -3914,34 +9652,47 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return box; } catch (e) { debugError('[HIVE] Recovery failed for "$boxName": $e'); - logError('Storage for "$boxName" unavailable - some settings may not persist'); + logError( + 'Storage for "$boxName" unavailable - some settings may not persist'); return null; } } /// Load remembered device from Hive storage Future _loadRememberedDevice() async { - // Skip on web - Web Bluetooth requires user interaction for each connection - if (kIsWeb) return; + try { + // Skip on web - Web Bluetooth requires user interaction for each connection + if (kIsWeb) return; - final box = await _openBoxSafely(_rememberedDeviceBoxName); - if (box == null) return; + final box = await _openBoxSafely(_rememberedDeviceBoxName); + if (box == null) return; - try { final json = box.get('device'); if (json != null) { - _rememberedDevice = RememberedDevice.fromJson(Map.from(json)); - debugLog('[APP] Loaded remembered device: ${_rememberedDevice!.name}'); + _rememberedDevice = + RememberedDevice.fromJson(Map.from(json)); + _selectedTransport = _rememberedDevice!.transportType; + debugLog( + '[APP] Loaded remembered device: ${_rememberedDevice!.name} (${_rememberedDevice!.transportType.name})'); notifyListeners(); } } catch (e) { debugLog('[APP] Failed to load remembered device: $e'); + } finally { + if (!_rememberedDeviceReady.isCompleted) { + _rememberedDeviceReady.complete(); + } } } /// Save device for quick reconnection - Future _saveRememberedDevice(DiscoveredDevice device) async { - // Skip on web - Web Bluetooth requires user interaction for each connection + Future _saveRememberedDevice( + DiscoveredDevice device, { + TransportType transportType = TransportType.ble, + String? tcpHost, + int? tcpPort, + String? serialPortPath, + }) async { if (kIsWeb) return; final box = await _openBoxSafely(_rememberedDeviceBoxName); @@ -3952,34 +9703,51 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { id: device.id, name: device.name, lastConnected: DateTime.now(), + transportType: transportType, + tcpHost: tcpHost, + tcpPort: tcpPort, + serialPortPath: serialPortPath, ); await box.put('device', remembered.toJson()); _rememberedDevice = remembered; - debugLog('[APP] Saved remembered device: ${device.name}'); + debugLog( + '[APP] Saved remembered device: ${device.name} (${transportType.name})'); notifyListeners(); } catch (e) { debugLog('[APP] Failed to save remembered device: $e'); } } - /// Reconnect to remembered device without scanning + /// Reconnect to remembered device without scanning. + /// Routes to the correct transport based on the remembered device's type. Future reconnectToRememberedDevice() async { if (_rememberedDevice == null) return; - if (kIsWeb) return; // Not supported on web - - final device = DiscoveredDevice( - id: _rememberedDevice!.id, - name: _rememberedDevice!.name, - ); - - // Pre-populate the BLE scan cache with remembered device info - // This ensures the device name is available during connect() - // (normally populated by scanning, but we're skipping the scan) - _bluetoothService.cacheDeviceInfo(device); + if (kIsWeb) return; - await connectToDevice(device); + switch (_rememberedDevice!.transportType) { + case TransportType.ble: + final device = DiscoveredDevice( + id: _rememberedDevice!.id, + name: _rememberedDevice!.name, + ); + _bluetoothService.cacheDeviceInfo(device); + await connectToDevice(device); + break; + case TransportType.tcp: + final host = _rememberedDevice!.tcpHost; + final port = _rememberedDevice!.tcpPort; + if (host != null && port != null) { + await connectViaTcp(host, port); + } else { + debugError('[APP] Cannot reconnect via TCP: missing host/port'); + } + break; + case TransportType.usbSerial: + debugLog('[APP] USB Serial reconnect requires user to select device'); + break; + } } /// Clear remembered device @@ -4015,10 +9783,25 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { final json = box.get('preferences'); if (json != null) { - _preferences = UserPreferences.fromJson(Map.from(json)); - debugLog('[APP] Loaded preferences: interval=${_preferences.autoPingInterval}s, ' + _preferences = + UserPreferences.fromJson(Map.from(json)); + debugLog( + '[APP] Loaded preferences: interval=${_preferences.autoPingInterval}s, ' 'ignoreCarpeater=${_preferences.ignoreCarpeater}, ' 'ignoreRepeaterId=${_preferences.ignoreRepeaterId}'); + + // Apply saved min ping distance to GpsService and PingService + _gpsService + .setMinPingDistance(_preferences.minPingDistanceMeters.toDouble()); + PingService.currentMinDistance = _preferences.minPingDistanceMeters; + + // Apply saved color vision type + PingColors.setColorVisionType( + ColorVisionType.values.firstWhere( + (e) => e.name == _preferences.colorVisionType, + orElse: () => ColorVisionType.none, + ), + ); } } catch (e) { debugLog('[APP] Failed to load preferences: $e'); @@ -4027,6 +9810,25 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + Future _loadWatchPairingPreference() async { + if (!_watchBridge.isSupportedPlatform) return; + + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) return; + try { + // OR with the in-memory observation because WCSession may report a + // pairing while startup persistence is still loading. That race must + // never turn the one-way flag back off. + final wasPaired = box.get('watch_has_ever_been_paired') == true; + if (wasPaired && !_hasEverPairedWatch) { + _hasEverPairedWatch = true; + _notifyWatchDiagnosticVisibilityChanged(); + } + } catch (error) { + debugError('[WATCH] Failed to load pairing history: $error'); + } + } + /// Save user preferences to Hive storage Future _savePreferences() async { final box = await _openBoxSafely(_preferencesBoxName); @@ -4034,6 +9836,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { await box.put('preferences', _preferences.toJson()); + await box.flush(); debugLog('[APP] Saved preferences'); } catch (e) { debugLog('[APP] Failed to save preferences: $e'); @@ -4053,7 +9856,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final raw = box.get('device_antenna_preferences'); if (raw != null) { _deviceAntennaPreferences = Map.from(raw as Map); - debugLog('[APP] Loaded antenna preferences for ${_deviceAntennaPreferences.length} device(s)'); + debugLog( + '[APP] Loaded antenna preferences for ${_deviceAntennaPreferences.length} device(s)'); } } catch (e) { debugLog('[APP] Failed to load device antenna preferences: $e'); @@ -4067,11 +9871,519 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { await box.put('device_antenna_preferences', _deviceAntennaPreferences); + await box.flush(); } catch (e) { debugLog('[APP] Failed to save device antenna preferences: $e'); } } + // ============================================ + // Device Power Override Persistence + // ============================================ + + /// Load per-device power overrides from Hive storage + Future _loadDevicePowerOverrides() async { + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) return; + + try { + final raw = box.get('device_power_overrides'); + if (raw != null) { + _devicePowerOverrides = (raw as Map).map( + (key, value) => + MapEntry(key.toString(), Map.from(value as Map)), + ); + debugLog( + '[APP] Loaded power overrides for ${_devicePowerOverrides.length} device(s)'); + } + } catch (e) { + debugLog('[APP] Failed to load device power overrides: $e'); + } + } + + /// Save per-device power overrides to Hive storage + Future _saveDevicePowerOverrides() async { + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) return; + + try { + await box.put('device_power_overrides', _devicePowerOverrides); + await box.flush(); + } catch (e) { + debugLog('[APP] Failed to save device power overrides: $e'); + } + } + + // ============================================ + // MyMeshMapper Account Persistence + // ============================================ + + Future _loadPortalAccountState() async { + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) return; + + try { + final rawAccount = box.get(_portalAccountKey); + if (rawAccount is Map) { + final cached = PortalAccount.fromCache(rawAccount); + if (cached != null) { + _portalAccount = cached; + _portalAccountService.hydrateAccount(cached); + } + } + + final rawLinked = box.get(_portalLinkedPubkeysKey); + if (rawLinked is List) { + _portalLinkedPubkeys = + rawLinked.map((entry) => entry.toString().toUpperCase()).toList(); + // The service is the single source of truth for this list — + // onAccountChanged mirrors it back WHOLESALE. Seed it too, or the first + // link/unlink would publish a list built from an empty cache and wipe + // every radio linked before this launch. + _portalAccountService.hydrateLinkedPubkeys(_portalLinkedPubkeys); + } + + final rawDeclined = box.get(_portalDeclinedKey); + if (rawDeclined is Map) { + _portalLinkDeclinedDevices = rawDeclined.map( + (key, value) => MapEntry(key.toString().toUpperCase(), value == true), + ); + } + + final rawUnsupported = box.get(_portalSignUnsupportedKey); + if (rawUnsupported is Map) { + _portalSignUnsupportedDevices = rawUnsupported.map( + (key, value) => MapEntry(key.toString().toUpperCase(), value == true), + ); + } + + debugLog('[ACCOUNT] Loaded state: ' + 'account=${_portalAccount?.username ?? 'none'}, ' + 'linked=${_portalLinkedPubkeys.length}, ' + 'declined=${_portalLinkDeclinedDevices.length}, ' + 'signUnsupported=${_portalSignUnsupportedDevices.length}'); + } catch (e) { + debugWarn('[ACCOUNT] Failed to load portal account state: $e'); + } + } + + Future _savePortalAccountState() async { + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) return; + + try { + final account = _portalAccount; + if (account == null) { + await box.delete(_portalAccountKey); + } else { + await box.put(_portalAccountKey, account.toCache()); + } + await box.put(_portalLinkedPubkeysKey, _portalLinkedPubkeys); + await box.put(_portalDeclinedKey, _portalLinkDeclinedDevices); + await box.put(_portalSignUnsupportedKey, _portalSignUnsupportedDevices); + await box.flush(); + } catch (e) { + debugWarn('[ACCOUNT] Failed to save portal account state: $e'); + } + } + + /// 8-char uppercase prefix — full public keys never reach a log. Duplicated + /// from `PortalAccountService._pk` on purpose: that one is private and this + /// is the only redaction the provider is allowed to log a pubkey through. + String _pkPrefix(String? pubkey) { + if (pubkey == null || pubkey.isEmpty) return 'none'; + final upper = pubkey.toUpperCase(); + return upper.length <= 8 ? upper : upper.substring(0, 8); + } + + /// Friendly label sent with `token` and `link` (server caps it at 60). + String? _portalDeviceLabel() { + final name = displayDeviceName; + if (name == null || name.isEmpty) return null; + return name.length <= 60 ? name : name.substring(0, 60); + } + + // ============================================ + // MyMeshMapper Account API (UI-facing) + // ============================================ + + /// Open the portal consent page in the system browser. + Future beginPortalSignIn() async { + if (kIsWeb) return false; + debugLog('[ACCOUNT] Sign-in requested'); + // A retry must not inherit the previous attempt's verdict. + _portalSignInError = null; + notifyListeners(); + return _portalAccountService.beginSignIn(); + } + + /// Drop the pending sign-in failure once the user has been shown it. + void clearPortalSignInError() { + if (_portalSignInError == null) return; + _portalSignInError = null; + notifyListeners(); + } + + /// Sign out locally and revoke the token server-side (best effort). + Future portalSignOut() async { + debugLog('[ACCOUNT] Sign-out requested'); + await _portalAccountService.logout(); + } + + /// Refresh the identity and linked-device list (throttled to 1/hour). + Future refreshPortalAccount({bool force = false}) async { + await _portalAccountService.refreshMe(force: force); + } + + /// How long the portal has told us to stay off `me`, or null when a refresh + /// is free to run. The Settings refresh button reads this so a rate-limited + /// tap says so instead of re-printing a stale device count. + Duration? get portalRefreshBackoff => + _portalAccountService.rateLimitBackoff('me'); + + /// Clear every persisted decline AND every sign-unsupported verdict, so both + /// kinds of suppressed radio are offered again. + /// + /// The sign-unsupported verdict is sticky by design (a radio without + /// CMD_SIGN must not be re-asked every connect), but it is a firmware fact, + /// not a permanent one: a firmware update adds the command. This is the only + /// user-facing way back, so the in-memory strike counters that feed the + /// verdict are reset with it — otherwise one more strike would re-latch it. + Future resetLinkPromptDeclines() async { + debugLog('[ACCOUNT] Clearing ' + '${_portalLinkDeclinedDevices.length} link decline(s) and ' + '${_portalSignUnsupportedDevices.length} sign-unsupported verdict(s)'); + _portalLinkDeclinedDevices.clear(); + _portalPromptedThisSession.clear(); + _portalSignUnsupportedDevices.clear(); + _portalUnsupportedStrikes.clear(); + _portalBadSignatureStrikes.clear(); + await _savePortalAccountState(); + notifyListeners(); + } + + /// Decide whether to surface the one-tap link prompt for the radio that just + /// connected. Never throws, never blocks anything, never shows an error. + Future _maybeOfferDeviceLink() async { + if (kIsWeb) return; + final pubkey = _devicePublicKey?.toUpperCase(); + + // Time-dependent guards live here so decideLinkFlow() stays pure. + if (pubkey != null) { + final retryAfter = _portalLinkRetryAfter[pubkey]; + if (retryAfter != null && DateTime.now().isBefore(retryAfter)) { + debugLog('[ACCOUNT] Link offer skipped for ${_pkPrefix(pubkey)}: ' + 'backing off after a failure'); + return; + } + if ((_portalLinkAttempts[pubkey] ?? 0) >= _portalLinkMaxAttempts) { + debugLog('[ACCOUNT] Link offer skipped for ${_pkPrefix(pubkey)}: ' + 'attempt cap reached'); + return; + } + } + + final decision = decideLinkFlow( + loggedIn: isPortalLoggedIn, + offlineMode: _preferences.offlineMode, + anonymousMode: _preferences.anonymousMode, + // _autoPingWasEnabled covers the reconnect path, where auto-ping is + // restored 500ms after the connection settles. + autoPingActive: _autoPingEnabled || _autoPingWasEnabled, + autoReconnecting: _isAutoReconnecting, + pubkey: pubkey, + declined: pubkey != null && _portalLinkDeclinedDevices[pubkey] == true, + linked: pubkey != null && _portalLinkedPubkeys.contains(pubkey), + signUnsupported: + pubkey != null && _portalSignUnsupportedDevices[pubkey] == true, + promptedThisAppSession: + pubkey != null && _portalPromptedThisSession.contains(pubkey), + ); + + if (decision == LinkFlowDecision.skip) { + debugLog('[ACCOUNT] Link offer skipped for ${_pkPrefix(pubkey)}'); + return; + } + + _portalPromptedThisSession.add(pubkey!); + _portalLinkPromptPubkey = pubkey; + _portalLinkPromptPending = true; + debugLog('[ACCOUNT] Offering a device link for ${_pkPrefix(pubkey)}'); + notifyListeners(); + } + + /// The user answered the link prompt. + Future respondToLinkPrompt(bool accepted) async { + final pubkey = _portalLinkPromptPubkey; + _portalLinkPromptPending = false; + _portalLinkPromptPubkey = null; + notifyListeners(); + + if (pubkey == null) { + return const PortalLinkOutcome(PortalLinkStatus.skipped); + } + if (!accepted) { + debugLog('[ACCOUNT] Link declined for ${_pkPrefix(pubkey)} — persisting'); + _portalLinkDeclinedDevices[pubkey] = true; + await _savePortalAccountState(); + notifyListeners(); + return const PortalLinkOutcome(PortalLinkStatus.skipped); + } + return _performDeviceLink(pubkey); + } + + /// "Link now" from Settings. Bypasses the decline list and the backoff cap — + /// the user asked for this explicitly. + Future startManualDeviceLink() async { + final pubkey = _devicePublicKey?.toUpperCase(); + if (kIsWeb || pubkey == null || !isPortalLoggedIn) { + return const PortalLinkOutcome(PortalLinkStatus.skipped); + } + debugLog('[ACCOUNT] Manual link requested for ${_pkPrefix(pubkey)}'); + + // The local ladder below is ours to clear; a block the SERVER asked for is + // not. Knocking on a live 429 re-arms a fresh penalty on the portal's + // sliding bucket, so an impatient tap would extend the user's own lockout. + final blocked = _portalAccountService.linkLaneBackoff; + if (blocked != null) { + debugLog('[ACCOUNT] Manual link blocked by portal for ' + '${blocked.inSeconds}s'); + return PortalLinkOutcome(PortalLinkStatus.failed, retryAfter: blocked); + } + + _portalLinkDeclinedDevices.remove(pubkey); + _portalLinkAttempts.remove(pubkey); + _portalLinkRetryAfter.remove(pubkey); + await _savePortalAccountState(); + return _performDeviceLink(pubkey); + } + + /// Unbind the connected radio from the account. + Future unlinkCurrentDevice() async { + final pubkey = _devicePublicKey?.toUpperCase(); + if (pubkey == null) return false; + final ok = await _portalAccountService.unlinkDevice(pubkey); + if (ok) { + _portalLinkedPubkeys.remove(pubkey); + await _savePortalAccountState(); + notifyListeners(); + } + return ok; + } + + /// nonce -> radio signature -> link. Every failure is silent to the user. + Future _performDeviceLink(String pubkey) async { + final connection = _meshCoreConnection; + if (connection == null) { + return const PortalLinkOutcome(PortalLinkStatus.skipped); + } + + final nonceHex = await _portalAccountService.requestNonce(pubkey); + if (nonceHex == null) return _recordLinkFailure(pubkey, 'nonce'); + + // Never hand the radio server-supplied bytes without checking their shape. + // The portal lane is specified as EXACTLY 32 random bytes (64 hex); the + // radio is a signing oracle, so anything else is refused. + if (!RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(nonceHex)) { + debugWarn('[ACCOUNT] Nonce refused: not 64 hex characters'); + return _recordLinkFailure(pubkey, 'bad_nonce'); + } + final nonceBytes = _hexToBytes(nonceHex); + if (nonceBytes.length != 32) { + debugWarn('[ACCOUNT] Nonce refused: ${nonceBytes.length} bytes, ' + 'expected 32'); + return _recordLinkFailure(pubkey, 'bad_nonce'); + } + + Uint8List signature; + try { + // The radio signs the RAW 32 bytes, never their hex text. + signature = await connection.sign(nonceBytes); + } on SignException catch (e) { + if (e.code == 'unsupported') { + // Two strikes before this is written down. A stats/battery ERR that + // was already in flight when the sign started is misattributed as + // 'unsupported' (see the ERR branch in connection.dart) — one stray + // ERR must never permanently disable linking for a radio. + final strikes = (_portalUnsupportedStrikes[pubkey] ?? 0) + 1; + _portalUnsupportedStrikes[pubkey] = strikes; + if (strikes >= 2) { + debugLog('[ACCOUNT] Radio has no CMD_SIGN — link disabled for ' + '${_pkPrefix(pubkey)}'); + _portalSignUnsupportedDevices[pubkey] = true; + await _savePortalAccountState(); + } else { + debugLog('[ACCOUNT] CMD_SIGN_START answered ERR for ' + '${_pkPrefix(pubkey)} (strike $strikes of 2) — could be a ' + 'stray ERR, not persisting yet'); + } + } else { + debugWarn('[ACCOUNT] Sign failed (${e.code}) — staying silent'); + } + return const PortalLinkOutcome(PortalLinkStatus.failed); + } catch (e) { + debugWarn('[ACCOUNT] Sign failed (${e.runtimeType}) — staying silent'); + return const PortalLinkOutcome(PortalLinkStatus.failed); + } + + // The radio just produced a signature, which disproves "this firmware has + // no CMD_SIGN". Drop the strike so an earlier stray ERR cannot combine + // with a later one to condemn a radio that demonstrably signs. + _portalUnsupportedStrikes.remove(pubkey); + + final result = await _portalAccountService.linkDevice( + pubkey: pubkey, + nonce: nonceHex, + signature: _bytesToHex(signature), + label: _portalDeviceLabel(), + ); + + switch (result) { + case LinkSuccess(:final already): + debugLog('[ACCOUNT] Device linked (already=$already)'); + if (!_portalLinkedPubkeys.contains(pubkey)) { + _portalLinkedPubkeys.add(pubkey); + } + _portalLinkAttempts.remove(pubkey); + _portalLinkRetryAfter.remove(pubkey); + // A signature the server accepted retires every "this radio cannot + // sign" verdict, including one already written to disk — otherwise a + // single later stray ERR would re-condemn a radio that just linked. + _portalUnsupportedStrikes.remove(pubkey); + _portalBadSignatureStrikes.remove(pubkey); + _portalSignUnsupportedDevices.remove(pubkey); + await _savePortalAccountState(); + notifyListeners(); + return PortalLinkOutcome( + PortalLinkStatus.linked, + accountName: _portalAccount?.displayName, + ); + + case LinkAdoptionRequired(:final devices): + debugLog('[ACCOUNT] Link needs browser adoption first ' + '($devices placeholder device(s))'); + return PortalLinkOutcome( + PortalLinkStatus.adoptionRequired, + adoptionDeviceCount: devices, + ); + + case LinkAlreadyLinkedOtherAccount(): + debugLog('[ACCOUNT] Radio belongs to another account — ' + 'persisting a decline so we stop asking'); + _portalLinkDeclinedDevices[pubkey] = true; + await _savePortalAccountState(); + notifyListeners(); + return const PortalLinkOutcome( + PortalLinkStatus.alreadyLinkedOtherAccount); + + case LinkUnauthorized(): + debugLog('[ACCOUNT] Link unauthorized — already signed out, silent'); + return const PortalLinkOutcome(PortalLinkStatus.unauthorized); + + case LinkNetworkError(:final detail): + debugWarn('[ACCOUNT] Link network error: $detail'); + return _recordLinkFailure(pubkey, 'network'); + + case LinkServerError(:final code, :final statusCode): + debugWarn('[ACCOUNT] Link server error: $code (HTTP $statusCode)'); + final outcome = _recordLinkFailure(pubkey, code); + if (code == 'bad_signature') { + // Count ONLY bad signatures here. _portalLinkAttempts also holds + // nonce and network failures, so reading it would let two unrelated + // network blips plus one genuine rejection condemn the radio. + final strikes = (_portalBadSignatureStrikes[pubkey] ?? 0) + 1; + _portalBadSignatureStrikes[pubkey] = strikes; + if (strikes >= 2) { + // Repeated bad signatures mean this firmware signs something else. + // Stop burning rate-limited nonces on it. + debugLog('[ACCOUNT] Repeated bad_signature — marking sign ' + 'unsupported for ${_pkPrefix(pubkey)}'); + _portalSignUnsupportedDevices[pubkey] = true; + await _savePortalAccountState(); + } else { + debugLog('[ACCOUNT] Server rejected the signature for ' + '${_pkPrefix(pubkey)} (strike $strikes of 2)'); + } + } + return outcome; + } + } + + /// Count a failure and schedule the next allowed attempt: 30s, 1m, 2m, 4m, + /// 8m — the whole ladder, because [_portalLinkMaxAttempts] stops the offer + /// at 5. The 30m clamp below is a guard for a raised cap, not a step anyone + /// reaches today. Nothing is shown to the user — the next connection simply + /// tries again. + PortalLinkOutcome _recordLinkFailure(String pubkey, String reason) { + final attempts = (_portalLinkAttempts[pubkey] ?? 0) + 1; + _portalLinkAttempts[pubkey] = attempts; + var backoffSeconds = math.min(30 * (1 << (attempts - 1)), 1800); + // When the failure was a 429 the portal named its own wait, and its + // buckets re-arm on every request made during a block. Never come back + // sooner than it asked, even if the ladder says we could. + final serverBackoff = _portalAccountService.linkLaneBackoff; + if (serverBackoff != null && serverBackoff.inSeconds > backoffSeconds) { + backoffSeconds = serverBackoff.inSeconds; + } + _portalLinkRetryAfter[pubkey] = + DateTime.now().add(Duration(seconds: backoffSeconds)); + debugLog('[ACCOUNT] Link attempt $attempts failed ($reason) for ' + '${_pkPrefix(pubkey)} — next try in ${backoffSeconds}s'); + return const PortalLinkOutcome(PortalLinkStatus.failed); + } + + Uint8List _hexToBytes(String hex) { + final length = hex.length ~/ 2; + final bytes = Uint8List(length); + for (var i = 0; i < length; i++) { + bytes[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16); + } + return bytes; + } + + String _bytesToHex(Uint8List bytes) => + bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + + // ============================================ + // Device Real Name Persistence (Anonymous Mode Recovery) + // ============================================ + + Future _loadDeviceRealNames() async { + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) return; + + try { + final raw = box.get('device_real_names'); + if (raw != null) { + _deviceRealNames = Map.from(raw as Map); + debugLog( + '[APP] Loaded real names for ${_deviceRealNames.length} device(s)'); + } + } catch (e) { + debugLog('[APP] Failed to load device real names: $e'); + } + } + + Future _saveDeviceRealNames() async { + final box = await _openBoxSafely(_preferencesBoxName); + if (box == null) return; + + try { + await box.put('device_real_names', _deviceRealNames); + await box.flush(); + } catch (e) { + debugLog('[APP] Failed to save device real names: $e'); + } + } + + Future _clearPersistedRealName(String publicKey) async { + if (_deviceRealNames.remove(publicKey) != null) { + await _saveDeviceRealNames(); + debugLog( + '[APP] Cleared persisted real name for device ${publicKey.substring(0, 16)}...'); + } + } + // ============================================ // Last Connected Device Persistence // ============================================ @@ -4082,10 +10394,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (box == null) return; try { - _lastConnectedDeviceName = box.get('last_connected_device_name') as String?; + _lastConnectedDeviceName = + box.get('last_connected_device_name') as String?; _lastConnectedPublicKey = box.get('last_connected_public_key') as String?; if (_lastConnectedDeviceName != null) { - debugLog('[APP] Loaded last connected device: $_lastConnectedDeviceName'); + debugLog( + '[APP] Loaded last connected device: $_lastConnectedDeviceName'); } } catch (e) { debugLog('[APP] Failed to load last connected device: $e'); @@ -4093,7 +10407,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// Save last connected device info to Hive storage - Future _saveLastConnectedDevice(String deviceName, String publicKey) async { + Future _saveLastConnectedDevice( + String deviceName, String publicKey) async { final box = await _openBoxSafely(_preferencesBoxName); if (box == null) return; @@ -4120,10 +10435,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { final lat = box.get('last_position_lat') as double?; final lon = box.get('last_position_lon') as double?; - if (lat != null && lon != null) { + if (lat != null && lon != null && isValidLatLng(lat, lon)) { _lastKnownPosition = (lat: lat, lon: lon); debugLog('[GPS] Loaded last position: $lat, $lon'); notifyListeners(); // Trigger UI rebuild so map can center on last position + } else if (lat != null && lon != null) { + debugWarn('[GPS] Ignoring invalid stored last position: $lat, $lon'); } } catch (e) { debugLog('[GPS] Failed to load last position: $e'); @@ -4132,6 +10449,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Save last known GPS position to Hive storage (throttled to every 30 seconds) Future _saveLastPosition(double lat, double lon) async { + // Never persist invalid coords — a corrupted last-known position would be + // loaded as the initial map center on next launch and abort the app. + if (!isValidLatLng(lat, lon)) { + debugWarn('[GPS] Skipping save of invalid last position: $lat, $lon'); + return; + } + // Throttle saves to every 30 seconds to avoid excessive Hive operations final now = DateTime.now(); if (_lastPositionSaveTime != null && @@ -4184,34 +10508,45 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[HIVE] Opening typed box "$_noiseFloorSessionBoxName"...'); try { - final box = await Hive.openBox(_noiseFloorSessionBoxName).timeout(timeout); - debugLog('[HIVE] Typed box "$_noiseFloorSessionBoxName" opened successfully'); + final box = + await Hive.openBox(_noiseFloorSessionBoxName) + .timeout(timeout); + debugLog( + '[HIVE] Typed box "$_noiseFloorSessionBoxName" opened successfully'); return box; } on TimeoutException { - debugError('[HIVE] Typed box "$_noiseFloorSessionBoxName" timed out - attempting recovery'); + debugError( + '[HIVE] Typed box "$_noiseFloorSessionBoxName" timed out - attempting recovery'); return _attemptNoiseFloorBoxRecovery(timeout); } catch (e) { - debugError('[HIVE] Typed box "$_noiseFloorSessionBoxName" failed: $e - attempting recovery'); + debugError( + '[HIVE] Typed box "$_noiseFloorSessionBoxName" failed: $e - attempting recovery'); return _attemptNoiseFloorBoxRecovery(timeout); } } /// Attempt to recover from Hive corruption for noise floor box - Future?> _attemptNoiseFloorBoxRecovery(Duration timeout) async { + Future?> _attemptNoiseFloorBoxRecovery( + Duration timeout) async { try { debugLog('[HIVE] Deleting corrupted box "$_noiseFloorSessionBoxName"...'); await Hive.deleteBoxFromDisk(_noiseFloorSessionBoxName); debugLog('[HIVE] Retrying open...'); // Notify user that cleanup happened - logError('Storage for "$_noiseFloorSessionBoxName" was corrupted and has been reset'); - - final box = await Hive.openBox(_noiseFloorSessionBoxName).timeout(timeout); - debugLog('[HIVE] Typed box "$_noiseFloorSessionBoxName" opened after recovery'); + logError( + 'Storage for "$_noiseFloorSessionBoxName" was corrupted and has been reset'); + + final box = + await Hive.openBox(_noiseFloorSessionBoxName) + .timeout(timeout); + debugLog( + '[HIVE] Typed box "$_noiseFloorSessionBoxName" opened after recovery'); return box; } catch (e) { debugError('[HIVE] Recovery failed for "$_noiseFloorSessionBoxName": $e'); - logError('Storage for "$_noiseFloorSessionBoxName" unavailable - noise floor graphs will not persist'); + logError( + 'Storage for "$_noiseFloorSessionBoxName" unavailable - noise floor graphs will not persist'); return null; } } @@ -4227,7 +10562,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { _storedNoiseFloorSessions = _noiseFloorSessionBox!.values.toList() ..sort((a, b) => b.startTime.compareTo(a.startTime)); // Newest first - debugLog('[GRAPH] Loaded ${_storedNoiseFloorSessions.length} stored noise floor sessions'); + debugLog( + '[GRAPH] Loaded ${_storedNoiseFloorSessions.length} stored noise floor sessions'); } catch (e) { debugError('[GRAPH] Failed to load noise floor sessions: $e'); _storedNoiseFloorSessions = []; @@ -4290,7 +10626,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_currentNoiseFloorSession == null) return; _currentNoiseFloorSession!.endTime = DateTime.now(); - debugLog('[GRAPH] Ended session: ${_currentNoiseFloorSession!.durationDisplay}, ' + debugLog( + '[GRAPH] Ended session: ${_currentNoiseFloorSession!.durationDisplay}, ' '${_currentNoiseFloorSession!.samples.length} samples, ' '${_currentNoiseFloorSession!.markers.length} markers'); @@ -4330,21 +10667,114 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + /// Show a historical session's ping markers on the map + void viewHistorySessionOnMap(NoiseFloorSession session) { + final markers = session.markers + .where((m) => m.latitude != null && m.longitude != null) + .toList(); + if (markers.isEmpty) return; + + _historySessionMarkers = markers; + _viewingHistorySession = true; + _requestMapTabSwitch = true; + debugLog('[GRAPH] Viewing session on map: ${markers.length} markers'); + notifyListeners(); + } + + /// Dismiss the history session map view + void clearHistorySession() { + if (!_viewingHistorySession) return; + _historySessionMarkers = null; + _viewingHistorySession = false; + debugLog('[GRAPH] Cleared history session map view'); + notifyListeners(); + } + // ============================================ // Cleanup // ============================================ + void _cancelPendingAutoPingRestore() { + _restoreAutoPingTimer?.cancel(); + _restoreAutoPingTimer = null; + _reconnectRestoreGeneration++; + } + + // ============================================ + // Map rebuild isolation (overheating fix) + // ============================================ + // + // The MapWidget (MapLibre GL) is by far the most expensive subtree. It used + // to rebuild on EVERY notifyListeners() — including high-frequency UI-only + // notifies (noise floor every 5s, battery, live stats) and the dense-mesh + // RX-pin storm (10-20x/sec) — which kept the GPU/CPU pinned and overheated + // the phone. Now the map is wrapped in a Selector keyed on [mapRevision] + // (see home_screen.dart `_buildLayout`), so it only rebuilds when a + // map-relevant change bumps the revision. UI-only notifies leave + // [mapRevision] untouched, so the map stays cached. + int _mapRevision = 0; + + /// Monotonic counter bumped whenever map-rendered data changes (markers, + /// position, history view). The map's Selector watches this; UI-only + /// notifies (noise floor, battery, stats) intentionally do not bump it. + int get mapRevision => _mapRevision; + + Timer? _mapThrottleTimer; + bool _mapThrottlePending = false; + static const Duration _mapThrottleWindow = Duration(milliseconds: 250); + + /// Bump the map revision and notify immediately. Use for low-frequency + /// map-relevant changes (TX ping added, ping window finalized, GPS position, + /// history view, marker/log clears, marker-style preference changes). + void _notifyMapNow() { + _mapRevision++; + notifyListeners(); + } + + /// Bump the map revision but coalesce notifications to ~4/sec. Use for + /// high-frequency map-relevant changes (passive RX pins, echo bursts) so a + /// dense mesh cannot force the map to rebuild 10-20x/sec. The pin data is + /// updated immediately; only the rebuild signal is throttled. + void _notifyMapThrottled() { + _mapRevision++; + if (_mapThrottleTimer != null) { + _mapThrottlePending = true; + return; + } + notifyListeners(); + _mapThrottleTimer = Timer(_mapThrottleWindow, () { + _mapThrottleTimer = null; + if (_mapThrottlePending) { + _mapThrottlePending = false; + notifyListeners(); + } + }); + } + @override @override void notifyListeners() { - if (!_isDisposed) super.notifyListeners(); + if (_isDisposed) return; + super.notifyListeners(); + _scheduleLiveActivitySync(); } @override void dispose() { _isDisposed = true; + if (_timerListenerAttached) { + _timerListenable.removeListener(_handleLiveActivityTimerChange); + } + _liveActivityService.dispose(); + _appIntentBridge.dispose(); + _watchBridge.diagnostics.removeListener(_handleWatchDiagnosticsChanged); + _watchBridge.dispose(); + _androidAuto.dispose(); WidgetsBinding.instance.removeObserver(this); _adapterStateSubscription?.cancel(); + _connectionSubscription?.cancel(); + _gpsStatusSubscription?.cancel(); + _gpsPositionSubscription?.cancel(); _logRxDataSubscription?.cancel(); _noiseFloorSubscription?.cancel(); _batterySubscription?.cancel(); @@ -4353,17 +10783,26 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _zoneCheckCountdownTimer?.cancel(); _reconnectTimer?.cancel(); _reconnectTimeoutTimer?.cancel(); - _tileRefreshTimer?.cancel(); + _restoreAutoPingTimer?.cancel(); + _idleDisconnectTimer?.cancel(); + _offlineAutoSaveTimer?.cancel(); + _zoneRefreshTimer?.cancel(); + _cancelZoneGraceTimers(); + _vectorFreshTimer?.cancel(); + _mapThrottleTimer?.cancel(); _unifiedRxHandler?.dispose(); _meshCoreConnection?.dispose(); _pingService?.dispose(); _gpsService.dispose(); _apiQueueService.dispose(); + _customApiService.dispose(); + _portalAccountService.dispose(); _offlineSessionService.dispose(); _apiService.dispose(); _bluetoothService.dispose(); _audioService.dispose(); _cooldownTimer.dispose(); + _manualPingCooldownTimer.dispose(); _autoPingTimer.dispose(); _rxWindowTimer.dispose(); _discoveryWindowTimer.dispose(); diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index bfa16f99..2b3a8e51 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -13,6 +13,10 @@ import '../models/user_preferences.dart'; import '../providers/app_state_provider.dart'; import '../utils/distance_formatter.dart'; import '../services/bluetooth/bluetooth_service.dart'; +import '../services/transport/android_serial_service.dart'; +import '../services/transport/tcp_service.dart'; +import '../services/transport/web_serial_factory.dart'; +import '../widgets/offline_mode_toggle.dart'; import '../widgets/regional_config_card.dart'; /// BLE device selection and connection screen @@ -23,15 +27,25 @@ class ConnectionScreen extends StatefulWidget { State createState() => _ConnectionScreenState(); } -class _ConnectionScreenState extends State with WidgetsBindingObserver { +class _ConnectionScreenState extends State + with WidgetsBindingObserver { + final _tcpHostController = TextEditingController(); + final _tcpPortController = TextEditingController(text: '5000'); + bool _tcpConnecting = false; + Future>>? _usbDevicesFuture; + Future>? _savedTcpFuture; + @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + _savedTcpFuture = TcpService.getSavedConnections(); } @override void dispose() { + _tcpHostController.dispose(); + _tcpPortController.dispose(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -74,90 +88,77 @@ class _ConnectionScreenState extends State with WidgetsBinding @override Widget build(BuildContext context) { final appState = context.watch(); - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; - - // Build FAB for scanning - show Cancel during scan, Scan when idle - // Hide FAB entirely during maintenance mode (maintenance UI has its own buttons) - // Hide FAB when Bluetooth is off (shows full-screen message instead) - // Hide FAB during auto-reconnect - Widget? fab; - if (appState.isScanning) { - // Show Cancel FAB during active scan - fab = isLandscape - ? FloatingActionButton.small( - onPressed: () => appState.stopScan(), - backgroundColor: Colors.red, - child: const Icon(Icons.close), - ) - : FloatingActionButton.extended( - onPressed: () => appState.stopScan(), - icon: const Icon(Icons.close), - label: const Text('Cancel'), - backgroundColor: Colors.red, - ); - } else if (appState.connectionStep == ConnectionStep.disconnected && - !appState.isAutoReconnecting && - (!appState.maintenanceMode || appState.offlineMode) && - !appState.isBluetoothOff) { - // Offline mode bypasses both zone and maintenance checks - final canScan = appState.offlineMode || appState.inZone == true; - fab = isLandscape - ? FloatingActionButton.small( - onPressed: canScan ? () => appState.startScan() : null, - backgroundColor: canScan ? null : Colors.grey, - child: const Icon(Icons.bluetooth_searching), - ) - : FloatingActionButton.extended( - onPressed: canScan ? () => appState.startScan() : null, - icon: const Icon(Icons.bluetooth_searching), - label: Text(appState.offlineMode - ? 'Scan' - : appState.gpsStatus == GpsStatus.disabled - ? 'GPS Disabled' - : appState.gpsStatus == GpsStatus.permissionDenied - ? 'GPS Required' - : appState.isCheckingZone - ? 'Checking Zone...' - : appState.inZone == true - ? 'Scan' - : appState.inZone == false - ? 'Outside Zone' - : 'Checking Zone...'), - backgroundColor: canScan ? null : Colors.grey, - ); - } - return Scaffold( - appBar: AppBar( - title: const Text('Connection'), - automaticallyImplyLeading: false, + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => FocusScope.of(context).unfocus(), + child: Scaffold( + appBar: AppBar( + toolbarHeight: 40, + title: const Text('Connection', style: TextStyle(fontSize: 18)), + automaticallyImplyLeading: false, + ), + body: _buildBody(context, appState), ), - body: _buildBody(context, appState), - floatingActionButton: fab, ); } Widget _buildBody(BuildContext context, AppStateProvider appState) { - // Show reconnecting UI + // Reconnecting and connection progress don't show the zone bar or bottom bar if (appState.connectionStep == ConnectionStep.reconnecting) { return _buildReconnectingView(context, appState); } - - // Show connection progress if (appState.connectionStep != ConnectionStep.disconnected && appState.connectionStep != ConnectionStep.connected && + appState.connectionStep != ConnectionStep.disconnecting && appState.connectionStep != ConnectionStep.error) { return _buildConnectionProgress(context, appState); } + // All other states: zone bar top, content middle, action bar bottom + return Column( + children: [ + _buildZoneStatusBar(context, appState), + Expanded(child: _buildStateContent(context, appState)), + _buildBottomBar(context, appState), + ], + ); + } + + /// Routes to the correct sub-view (zone bar is already rendered above) + Widget _buildStateContent(BuildContext context, AppStateProvider appState) { + // Show zone transfer in progress + if (appState.isZoneTransferInProgress) { + return _buildZoneTransferView(context, appState); + } + + // Show zone grace period state + if (appState.isInZoneGracePeriod) { + return _buildZoneGraceView(context, appState); + } + + // Show disconnecting state + if (appState.connectionStep == ConnectionStep.disconnecting) { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Disconnecting...'), + ], + ), + ); + } + // Show connected state if (appState.isConnected) { - // Show path hash mode warning popup if pending final pathWarning = appState.pendingPathHashWarning; if (pathWarning != null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - _showPathHashWarning(context, pathWarning.hopBytes, pathWarning.reason); + _showPathHashWarning( + context, pathWarning.hopBytes, pathWarning.reason); appState.clearPathHashWarning(); }); } @@ -173,10 +174,181 @@ class _ConnectionScreenState extends State with WidgetsBinding return _buildDeviceList(context, appState); } - Widget _buildConnectionProgress(BuildContext context, AppStateProvider appState) { + /// Persistent bottom action bar: transport picker + offline toggle + action button + Widget _buildBottomBar(BuildContext context, AppStateProvider appState) { + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; + + // Landscape has little vertical room: stacking the transport picker under + // the action row pushed the Scan/Connect button off-screen, making it + // impossible to reconnect after a drop (#356). In landscape, keep the + // picker, offline toggle, and action button on a single row so the action + // button is always reachable. + if (isLandscape) { + final showPicker = + !appState.isConnected && _availableTransports().length > 1; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 16, 8), + child: Row( + children: [ + if (showPicker) ...[ + Expanded(child: _buildTransportPicker(context, appState)), + const SizedBox(width: 8), + ], + const Expanded(child: OfflineModeToggle()), + const SizedBox(width: 8), + Expanded(child: _buildActionButton(context, appState)), + ], + ), + ); + } + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Expanded(child: OfflineModeToggle()), + const SizedBox(width: 12), + Expanded(child: _buildActionButton(context, appState)), + ], + ), + if (!appState.isConnected) ...[ + const SizedBox(height: 8), + _buildTransportPicker(context, appState), + ], + ], + ), + ); + } + + /// The right-side action button: Scan/Connect, Cancel, or Disconnect + Widget _buildActionButton(BuildContext context, AppStateProvider appState) { + if (appState.connectionStep == ConnectionStep.disconnecting) { + return _buildBottomButton( + icon: Icons.hourglass_top, + label: 'Disconnecting', + color: Colors.grey, + onPressed: null, + ); + } + + if (appState.isConnected) { + return _buildBottomButton( + icon: Icons.link_off, + label: 'Disconnect', + color: Colors.red, + onPressed: () async => await appState.disconnect(), + ); + } + + if (appState.isScanning) { + return _buildBottomButton( + icon: Icons.close, + label: 'Cancel', + color: Colors.red, + onPressed: () => appState.stopScan(), + ); + } + + final canConnect = appState.connectionStep == ConnectionStep.disconnected && + !appState.isAutoReconnecting && + (!appState.maintenanceMode || appState.offlineMode) && + (appState.offlineMode || appState.inZone == true); + + switch (appState.selectedTransport) { + case TransportType.ble: + return _buildBottomButton( + icon: Icons.bluetooth_searching, + label: 'Scan', + color: Theme.of(context).colorScheme.primary, + onPressed: canConnect && !appState.isBluetoothOff + ? () => appState.startScan() + : null, + ); + case TransportType.tcp: + final hostFilled = _tcpHostController.text.trim().isNotEmpty; + return _buildBottomButton( + icon: Icons.lan, + label: _tcpConnecting ? 'Connecting' : 'Connect', + color: Theme.of(context).colorScheme.primary, + onPressed: canConnect && hostFilled && !_tcpConnecting + ? () => _connectTcp(appState) + : null, + ); + case TransportType.usbSerial: + if (kIsWeb) { + return _buildBottomButton( + icon: Icons.usb, + label: 'Connect', + color: Theme.of(context).colorScheme.primary, + onPressed: canConnect ? () => _connectWebSerial(appState) : null, + ); + } + return _buildBottomButton( + icon: Icons.refresh, + label: 'Refresh', + color: Theme.of(context).colorScheme.primary, + onPressed: canConnect + ? () => setState(() { + _usbDevicesFuture = + AndroidSerialService.getAvailablePorts(); + }) + : null, + ); + } + } + + /// Styled button matching OfflineModeToggle shape + Widget _buildBottomButton({ + required IconData icon, + required String label, + required Color color, + VoidCallback? onPressed, + }) { + final enabled = onPressed != null; + final effectiveColor = enabled ? color : Colors.grey; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: effectiveColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: effectiveColor.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 18, color: effectiveColor), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: effectiveColor, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildConnectionProgress( + BuildContext context, AppStateProvider appState) { final step = appState.connectionStep; final totalSteps = ConnectionStepExtension.totalSteps; - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; return SafeArea( child: Center( @@ -210,8 +382,164 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - Widget _buildReconnectingView(BuildContext context, AppStateProvider appState) { - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + Widget _buildZoneGraceView(BuildContext context, AppStateProvider appState) { + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; + final nearestName = appState.nearestZoneName; + final nearestDistance = appState.nearestZoneDistanceKm; + final hasNearestInfo = nearestName != null && nearestDistance != null; + + return SafeArea( + child: Center( + child: Padding( + padding: EdgeInsets.all(isLandscape ? 16 : 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.location_off, + color: Colors.orange.shade400, + size: isLandscape ? 28 : 36, + ), + SizedBox(height: isLandscape ? 12 : 20), + Text( + 'Out of Zone', + style: Theme.of(context).textTheme.titleMedium, + ), + if (hasNearestInfo) ...[ + const SizedBox(height: 8), + Text( + 'Nearest: $nearestName (${nearestDistance.toStringAsFixed(1)} km)', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), + ), + textAlign: TextAlign.center, + ), + ], + SizedBox(height: isLandscape ? 12 : 16), + Text( + appState.zoneGraceCountdownFormatted, + style: TextStyle( + color: Colors.orange.shade400, + fontSize: isLandscape ? 22 : 28, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: isLandscape ? 8 : 12), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.5), + ), + ), + const SizedBox(width: 8), + Text( + 'Searching for zone...', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.5), + ), + ), + ], + ), + SizedBox(height: isLandscape ? 16 : 24), + OutlinedButton( + onPressed: () => appState.cancelZoneGracePeriod(), + child: const Text('Cancel'), + ), + ], + ), + ), + ), + ); + } + + Widget _buildZoneTransferView( + BuildContext context, AppStateProvider appState) { + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; + final from = appState.zoneTransferFrom ?? '?'; + final to = appState.zoneTransferTo ?? '?'; + + return SafeArea( + child: Center( + child: Padding( + padding: EdgeInsets.all(isLandscape ? 16 : 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + SizedBox(height: isLandscape ? 12 : 24), + Text( + 'Changing Zone...', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + '$from → $to', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), + ), + ), + SizedBox(height: isLandscape ? 8 : 12), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.5), + ), + ), + const SizedBox(width: 8), + Text( + 'Re-authenticating...', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.5), + ), + ), + ], + ), + SizedBox(height: isLandscape ? 16 : 24), + OutlinedButton( + onPressed: () => appState.cancelZoneTransfer(), + child: const Text('Cancel'), + ), + ], + ), + ), + ), + ); + } + + Widget _buildReconnectingView( + BuildContext context, AppStateProvider appState) { + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; final deviceName = appState.rememberedDevice?.displayName ?? 'device'; return SafeArea( @@ -231,14 +559,20 @@ class _ConnectionScreenState extends State with WidgetsBinding Text( 'Attempt ${appState.reconnectAttempt} of 3', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), ), ), const SizedBox(height: 4), Text( deviceName, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.5), ), ), SizedBox(height: isLandscape ? 16 : 24), @@ -257,133 +591,145 @@ class _ConnectionScreenState extends State with WidgetsBinding // Get device name - uses displayDeviceName which prefers SelfInfo name over BLE advertisement name final deviceName = appState.displayDeviceName ?? 'Unknown'; - // Parse version from manufacturer string and use shortName from device model for hardware - // Format examples: - // - "MeshCore (Heltec V3) v1.10.0" - // - "Ikoka Stick-E22-30dBm (Xiao_nrf52) nightly-e31c46f" + // Extract version from firmware version string (v7+), fall back to manufacturer string String? version; - final manufacturerString = appState.manufacturerString; - if (manufacturerString != null) { - // Use regex to find version pattern directly instead of splitting - // Match: v followed by digits/dots, OR nightly- followed by hex, OR just digits.digits - final versionRegex = RegExp(r'(v[\d.]+|nightly-[a-f0-9]+|\d+\.\d+\.\d+)'); - final match = versionRegex.firstMatch(manufacturerString); - if (match != null) { - version = match.group(1); + final fwString = appState.firmwareVersionString; + if (fwString != null && fwString.isNotEmpty) { + final semverMatch = RegExp(r'(\d+\.\d+\.\d+)').firstMatch(fwString); + if (semverMatch != null) { + version = semverMatch.group(1); + } else { + final nightlyMatch = + RegExp(r'(nightly-[a-f0-9]+)').firstMatch(fwString); + if (nightlyMatch != null) { + version = nightlyMatch.group(1); + } + } + } + if (version == null) { + final manufacturerString = appState.manufacturerString; + if (manufacturerString != null) { + final versionRegex = + RegExp(r'(v[\d.]+|nightly-[a-f0-9]+|\d+\.\d+\.\d+)'); + final match = versionRegex.firstMatch(manufacturerString); + if (match != null) { + version = match.group(1); + } } } - // Use shortName from device model if available, otherwise fall back to manufacturer string - final hardware = appState.deviceModel?.shortName ?? manufacturerString ?? 'Unknown'; - - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final hardware = appState.deviceModel?.shortName ?? + appState.manufacturerString ?? + 'Unknown'; + final platform = appState.deviceModel?.platform; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; + final prefs = appState.preferences; + final isAutoMode = appState.autoPingEnabled; + final isPowerSet = prefs.autoPowerSet || + prefs.powerLevelSet || + appState.deviceModel != null; - // Build device info card - final deviceInfoCard = Card( + // Compact device summary card + final deviceSummaryCard = Card( child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + // Header: BT icon + name/status Row( children: [ - const Icon( - Icons.bluetooth_connected, - color: Colors.green, - size: 28, - ), - const SizedBox(width: 12), + const Icon(Icons.bluetooth_connected, + color: Colors.green, size: 20), + const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Connected', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Colors.green, + deviceName, + style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: FontWeight.bold, ), + overflow: TextOverflow.ellipsis, ), Text( - deviceName, - style: Theme.of(context).textTheme.bodyLarge, + 'Connected', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.green, + ), ), ], ), ), ], ), - const Divider(height: 20), - _buildInfoRow('Hardware', hardware), - _buildInfoRow('Version', version ?? 'Unknown'), - if (appState.deviceModel != null) - _buildInfoRow('Platform', appState.deviceModel!.platform), - if (appState.devicePublicKey != null) + const SizedBox(height: 8), + + // Detail chips: hardware, version, platform + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + _buildDetailChip(context, Icons.memory, hardware), + if (version != null) + _buildDetailChip(context, Icons.code, version), + if (platform != null) + _buildDetailChip(context, Icons.developer_board, platform), + ], + ), + + // Power level row + const SizedBox(height: 4), + _buildPowerRow(context, appState, isPowerSet, isAutoMode, prefs), + + // Radio config row (freq/bandwidth/SF/CR), under Power Level + if (appState.radioConfigDisplay != null) ...[ + const SizedBox(height: 4), + _buildRadioRow(context, appState.radioConfigDisplay!), + ], + + // Public key row + if (appState.devicePublicKey != null) ...[ + const SizedBox(height: 8), _buildPublicKeyRow(context, appState.devicePublicKey!), - if (appState.authType != null && !appState.offlineMode) + ], + + // Registered via row + if (appState.authType != null && !appState.offlineMode) ...[ + const SizedBox(height: 4), _buildAuthTypeRow(context, appState.authType!), + ], ], ), ), ); - // Build disconnect button - final disconnectButton = ElevatedButton.icon( - onPressed: () async { - await appState.disconnect(); - }, - icon: const Icon(Icons.bluetooth_disabled), - label: const Text('Disconnect'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - ), + // Compact channels card + final channelsCard = RegionalConfigCard( + zoneName: appState.offlineMode ? null : appState.zoneName, + zoneCode: appState.offlineMode ? null : appState.zoneCode, + channels: appState.offlineMode ? [] : appState.regionalChannels, + scope: appState.offlineMode ? null : appState.scope, + isOfflineMode: appState.offlineMode, + compact: true, ); if (isLandscape) { - // Landscape: two-column layout return SafeArea( child: Padding( padding: const EdgeInsets.all(12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Left column: device info + disconnect Expanded( - child: Column( - children: [ - Expanded( - child: SingleChildScrollView( - child: deviceInfoCard, - ), - ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: disconnectButton, - ), - ], - ), + child: SingleChildScrollView(child: deviceSummaryCard), ), const SizedBox(width: 12), - // Right column: power + regional config Expanded( - child: SingleChildScrollView( - child: Column( - children: [ - _buildPowerLevelCard(context, appState), - const SizedBox(height: 12), - RegionalConfigCard( - zoneName: appState.offlineMode ? null : appState.zoneName, - zoneCode: appState.offlineMode ? null : appState.zoneCode, - channels: appState.offlineMode ? [] : appState.regionalChannels, - scope: appState.offlineMode ? null : appState.scope, - isOfflineMode: appState.offlineMode, - ), - ], - ), - ), + child: SingleChildScrollView(child: channelsCard), ), ], ), @@ -391,65 +737,180 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - // Portrait: vertical layout - return Column( - children: [ - Expanded( - child: ListView( - padding: const EdgeInsets.all(16), - children: [ - deviceInfoCard, - const SizedBox(height: 16), - _buildPowerLevelCard(context, appState), - const SizedBox(height: 16), - RegionalConfigCard( - zoneName: appState.offlineMode ? null : appState.zoneName, - zoneCode: appState.offlineMode ? null : appState.zoneCode, - channels: appState.offlineMode ? [] : appState.regionalChannels, - scope: appState.offlineMode ? null : appState.scope, - isOfflineMode: appState.offlineMode, - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.all(16), - child: SizedBox( - width: double.infinity, - child: disconnectButton, - ), - ), - ], - ); - } - - Widget _buildInfoRow(String label, String value) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( + // Portrait: compact vertical layout (bottom bar provided by _buildBody) + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SizedBox( - width: 120, - child: Text( - label, - style: const TextStyle(fontWeight: FontWeight.w500), - ), - ), - Expanded(child: Text(value)), + deviceSummaryCard, + const SizedBox(height: 12), + channelsCard, ], ), ); } - Widget _buildPublicKeyRow(BuildContext context, String publicKey) { - // Show truncated key for display (first 8 + ... + last 8) - final displayKey = publicKey.length > 16 - ? '${publicKey.substring(0, 8)}...${publicKey.substring(publicKey.length - 8)}' - : publicKey; - + /// Power level row matching Registered via / Public Key format + Widget _buildPowerRow( + BuildContext context, + AppStateProvider appState, + bool isPowerSet, + bool isAutoMode, + UserPreferences prefs, + ) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( + child: InkWell( + onTap: isAutoMode + ? null + : () => _showPowerLevelSelector(context, appState), + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + const SizedBox( + width: 120, + child: Text( + 'Power Level', + style: TextStyle(fontWeight: FontWeight.w500), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.bolt, + size: 16, + color: + isPowerSet ? Colors.amber.shade700 : Colors.orange), + const SizedBox(width: 4), + Text( + isPowerSet + ? prefs.powerLevelDisplay + : 'Unknown - tap to set', + style: TextStyle( + fontWeight: FontWeight.w500, + color: isPowerSet ? null : Colors.orange, + ), + ), + if (prefs.autoPowerSet) ...[ + const SizedBox(width: 4), + const Icon(Icons.auto_awesome, + size: 14, color: Colors.green), + const SizedBox(width: 2), + const Text( + 'Auto', + style: TextStyle( + fontSize: 12, + color: Colors.green, + fontWeight: FontWeight.bold, + ), + ), + ] else if (prefs.powerLevelSet && + !prefs.autoPowerSet && + appState.deviceModel != null) ...[ + const SizedBox(width: 4), + const Icon(Icons.edit, size: 14, color: Colors.orange), + const SizedBox(width: 2), + const Text( + 'Override', + style: TextStyle( + fontSize: 12, + color: Colors.orange, + fontWeight: FontWeight.bold, + ), + ), + ], + if (!isAutoMode) ...[ + const SizedBox(width: 4), + const Icon(Icons.chevron_right, + size: 16, color: Colors.grey), + ], + ], + ), + ], + ), + ), + ), + ); + } + + /// Radio configuration row (frequency/bandwidth/SF/CR), shown under Power Level. + /// Read-only — the app only reports the device's radio settings, never changes them. + /// The value ("910.525 MHz · 62.5 kHz · SF7 · CR5") is split onto two tidy lines — + /// freq + bandwidth, then SF + CR — so it never wraps mid-value in the narrow card. + Widget _buildRadioRow(BuildContext context, String value) { + final parts = value.split(' · '); + final line1 = parts.length >= 2 ? parts.take(2).join(' · ') : value; + final line2 = parts.length > 2 ? parts.skip(2).join(' · ') : ''; + const valueStyle = TextStyle(fontWeight: FontWeight.w500); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox( + width: 120, + child: Text( + 'Radio', + style: TextStyle(fontWeight: FontWeight.w500), + ), + ), + Icon(Icons.radio, size: 16, color: Colors.blue.shade400), + const SizedBox(width: 4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(line1, style: valueStyle), + if (line2.isNotEmpty) Text(line2, style: valueStyle), + ], + ), + ), + ], + ), + ), + ); + } + + /// Small detail chip with icon + text + Widget _buildDetailChip(BuildContext context, IconData icon, String text) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 12, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: 4), + Text( + text, + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + Widget _buildPublicKeyRow(BuildContext context, String publicKey) { + // Show truncated key for display (first 8 + ... + last 8) + final displayKey = publicKey.length > 16 + ? '${publicKey.substring(0, 8)}...${publicKey.substring(publicKey.length - 8)}' + : publicKey; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( children: [ const SizedBox( width: 120, @@ -585,17 +1046,19 @@ class _ConnectionScreenState extends State with WidgetsBinding decoration: BoxDecoration( color: Colors.blue.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10), - border: Border.all(color: Colors.blue.withValues(alpha: 0.4)), + border: + Border.all(color: Colors.blue.withValues(alpha: 0.4)), ), - child: const Icon(Icons.verified_user, color: Colors.blue, size: 20), + child: const Icon(Icons.verified_user, + color: Colors.blue, size: 20), ), const SizedBox(width: 12), Expanded( child: Text( 'Registration Methods', style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), ), IconButton( @@ -619,7 +1082,8 @@ class _ConnectionScreenState extends State with WidgetsBinding color: Colors.green, title: 'Mesh', trustLevel: 'Most trusted', - description: 'Your companion\'s signed advert was heard over the mesh by a LetsMesh Observer and collected via MQTT. This confirms your radio is actively participating in the network.', + description: + 'Your companion\'s signed advert was heard over the mesh by a LetsMesh Observer and collected via MQTT. This confirms your radio is actively participating in the network.', isCurrentType: currentType == 'Mesh', ), const SizedBox(height: 12), @@ -629,7 +1093,8 @@ class _ConnectionScreenState extends State with WidgetsBinding color: Colors.blue, title: 'API', trustLevel: 'Trusted', - description: 'We registered your companion using a signed advert via the MeshMapper API. We haven\'t heard you over the mesh yet, but your device identity is verified.', + description: + 'We registered your companion using a signed advert via the MeshMapper API. We haven\'t heard you over the mesh yet, but your device identity is verified.', isCurrentType: currentType == 'API', ), const SizedBox(height: 12), @@ -639,7 +1104,8 @@ class _ConnectionScreenState extends State with WidgetsBinding color: Colors.orange, title: 'Manual', trustLevel: 'Basic', - description: 'An administrator manually added your device. Go wardriving to upgrade to Mesh verification!', + description: + 'An administrator manually added your device. Go wardriving to upgrade to Mesh verification!', isCurrentType: currentType == 'Manual', ), ], @@ -668,7 +1134,9 @@ class _ConnectionScreenState extends State with WidgetsBinding decoration: BoxDecoration( color: isCurrentType ? color.withValues(alpha: 0.1) : null, borderRadius: BorderRadius.circular(8), - border: isCurrentType ? Border.all(color: color.withValues(alpha: 0.4)) : null, + border: isCurrentType + ? Border.all(color: color.withValues(alpha: 0.4)) + : null, ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -693,13 +1161,15 @@ class _ConnectionScreenState extends State with WidgetsBinding trustLevel, style: TextStyle( fontSize: 11, - color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + color: + theme.colorScheme.onSurface.withValues(alpha: 0.6), ), ), if (isCurrentType) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(4), @@ -732,55 +1202,13 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - Widget _buildPowerLevelCard(BuildContext context, AppStateProvider appState) { - final prefs = appState.preferences; - final isAutoMode = appState.autoPingEnabled; - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; - - return Card( - child: ListTile( - leading: const Icon(Icons.power), - title: const Text('Power Level'), - subtitle: Builder( - builder: (context) { - if (!isPowerSet) { - return Text( - 'Unknown hardware - select power', - style: TextStyle(color: Colors.orange.shade700), - ); - } - return Row( - children: [ - Text(prefs.powerLevelDisplay), - if (prefs.autoPowerSet) ...[ - const SizedBox(width: 8), - const Icon(Icons.auto_awesome, size: 14, color: Colors.green), - const SizedBox(width: 4), - const Text( - 'Auto', - style: TextStyle( - fontSize: 12, - color: Colors.green, - fontWeight: FontWeight.bold, - ), - ), - ], - ], - ); - }, - ), - trailing: const Icon(Icons.chevron_right), - enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showPowerLevelSelector(context, appState), - ), - ); - } - - void _showPowerLevelSelector(BuildContext context, AppStateProvider appState) { + void _showPowerLevelSelector( + BuildContext context, AppStateProvider appState) { final prefs = appState.preferences; final deviceModel = appState.deviceModel; // Only show selection if power has been set (auto or manual) - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || deviceModel != null; + final isPowerSet = + prefs.autoPowerSet || prefs.powerLevelSet || deviceModel != null; final currentPower = isPowerSet ? prefs.powerLevel : null; // Helper to handle power selection with confirmation for overrides @@ -810,6 +1238,7 @@ class _ConnectionScreenState extends State with WidgetsBinding powerLevel: value, txPower: PowerLevel.getTxPower(value), autoPowerSet: false, // Clear auto flag on override + powerLevelSet: true, // Mark as manually overridden ), ); Navigator.pop(context); // Close confirmation @@ -827,7 +1256,7 @@ class _ConnectionScreenState extends State with WidgetsBinding powerLevel: value, txPower: PowerLevel.getTxPower(value), autoPowerSet: false, - powerLevelSet: true, // Mark as manually set + powerLevelSet: true, // Mark as manually set ), ); Navigator.pop(context); @@ -842,23 +1271,31 @@ class _ConnectionScreenState extends State with WidgetsBinding mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Auto-detection info banner - if (prefs.autoPowerSet && deviceModel != null) + // Auto-detection / override info banner + if (deviceModel != null) Container( padding: const EdgeInsets.all(12), margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.1), - border: Border.all(color: Colors.green), + color: (prefs.autoPowerSet ? Colors.green : Colors.orange) + .withValues(alpha: 0.1), + border: Border.all( + color: prefs.autoPowerSet ? Colors.green : Colors.orange), borderRadius: BorderRadius.circular(8), ), child: Row( children: [ - const Icon(Icons.auto_awesome, size: 20, color: Colors.green), + Icon( + prefs.autoPowerSet ? Icons.auto_awesome : Icons.edit, + size: 20, + color: prefs.autoPowerSet ? Colors.green : Colors.orange, + ), const SizedBox(width: 8), Expanded( child: Text( - 'Auto-detected: ${deviceModel.shortName} ${deviceModel.power}W', + prefs.autoPowerSet + ? 'Auto-detected: ${deviceModel.shortName} ${deviceModel.power}W' + : 'Override active \u2014 ${deviceModel.shortName} auto-detects as ${deviceModel.power}W', style: const TextStyle(fontSize: 12), ), ), @@ -878,7 +1315,8 @@ class _ConnectionScreenState extends State with WidgetsBinding mainAxisSize: MainAxisSize.min, children: PowerLevel.values.map((power) { final isSelected = power == currentPower; - final isRecommended = prefs.autoPowerSet && deviceModel != null && power == deviceModel.power; + final isRecommended = + deviceModel != null && power == deviceModel.power; // Create a temp preferences object to get the display string with dBm final tempPrefs = UserPreferences(powerLevel: power); @@ -886,10 +1324,12 @@ class _ConnectionScreenState extends State with WidgetsBinding return RadioListTile( title: Row( children: [ - Flexible(child: Text(tempPrefs.powerLevelDisplayWithDbm)), + Flexible( + child: Text(tempPrefs.powerLevelDisplayWithDbm)), if (isRecommended) ...[ const SizedBox(width: 8), - const Icon(Icons.check_circle, size: 16, color: Colors.green), + const Icon(Icons.check_circle, + size: 16, color: Colors.green), ], ], ), @@ -925,6 +1365,22 @@ class _ConnectionScreenState extends State with WidgetsBinding ], ), actions: [ + if (!prefs.autoPowerSet && deviceModel != null) + TextButton( + onPressed: () { + appState.updatePreferences( + prefs.copyWith( + powerLevel: deviceModel.power, + txPower: deviceModel.txPower, + autoPowerSet: true, + powerLevelSet: false, + ), + ); + Navigator.pop(context); + }, + child: const Text('Reset to Auto', + style: TextStyle(color: Colors.green)), + ), TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), @@ -935,7 +1391,8 @@ class _ConnectionScreenState extends State with WidgetsBinding } Widget _buildError(BuildContext context, AppStateProvider appState) { - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; return SafeArea( child: Center( @@ -945,18 +1402,24 @@ class _ConnectionScreenState extends State with WidgetsBinding mainAxisSize: MainAxisSize.min, children: [ Icon( - Icons.error_outline, + appState.isNetworkError ? Icons.cloud_off : Icons.error_outline, size: isLandscape ? 48 : 64, - color: Colors.red, + color: appState.isNetworkError ? Colors.orange : Colors.red, ), SizedBox(height: isLandscape ? 8 : 16), Text( - appState.isAuthError ? 'Authentication Failed' : 'Connection Failed', + appState.isNetworkError + ? 'Server Unreachable' + : appState.isAuthError + ? 'Authentication Failed' + : 'Connection Failed', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), Text( - appState.connectionError ?? 'Unknown error', + appState.isNetworkError + ? 'MeshMapper services unreachable, try again or use offline mode.' + : appState.connectionError ?? 'Unknown error', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), @@ -988,18 +1451,28 @@ class _ConnectionScreenState extends State with WidgetsBinding locationIcon = Icons.gps_off; locationText = '-'; locationColor = Colors.grey; - // Check maintenance mode + // Check maintenance mode } else if (appState.maintenanceMode) { locationIcon = Icons.engineering; locationText = 'Maintenance'; locationColor = Colors.orange; - // Network error: show wifi off indicator + // Network error: show wifi off indicator } else if (appState.zoneCheckErrorReason == 'network') { locationIcon = Icons.wifi_off; locationText = 'No Internet'; locationColor = Colors.red; - // Show "Checking Zone..." whenever a zone check is in progress - // This provides consistent UI feedback during both initial and re-checks + // Clock error: a stale position is a wrong phone clock, not bad GPS + } else if (appState.zoneCheckErrorReason == 'gps_stale') { + locationIcon = Icons.schedule; + locationText = 'Clock Out of Sync'; + locationColor = Colors.orange; + // GPS error: show GPS issue indicator + } else if (appState.zoneCheckErrorReason == 'gps_inaccurate') { + locationIcon = Icons.gps_off; + locationText = 'GPS Unavailable'; + locationColor = Colors.orange; + // Show "Checking Zone..." whenever a zone check is in progress + // This provides consistent UI feedback during both initial and re-checks } else if (appState.isCheckingZone) { locationIcon = Icons.location_searching; locationText = 'Checking Zone...'; @@ -1024,9 +1497,17 @@ class _ConnectionScreenState extends State with WidgetsBinding final slotsMax = appState.zoneSlotsMax; final hasSlots = slotsAvailable != null && slotsMax != null; + // A flood-disabled region hands out no TX slots at all. Before connecting + // the auth flag isn't known yet, so fall back to the shape of the zone: a + // 0-max zone isn't FULL, it simply offers no TX slots. Either way it's a + // regional setting, so show info blue and drop the misleading 0/0 count. + final passiveOnly = appState.floodDisabled || slotsMax == 0; + // Slots color based on availability Color slotsColor; - if (!hasSlots) { + if (passiveOnly) { + slotsColor = Colors.blue; + } else if (!hasSlots) { slotsColor = Colors.grey; } else if (slotsAvailable == 0) { slotsColor = Colors.red; @@ -1052,30 +1533,47 @@ class _ConnectionScreenState extends State with WidgetsBinding padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: Row( children: [ - // Location chip (city name) - _buildChip( - icon: locationIcon, - text: locationText, - color: locationColor, - ), + // The leading group absorbs the leftover width (replacing a + // Spacer) so the slots chip stays pinned right while a long city + // name ellipsizes instead of overflowing on a narrow phone. + Expanded( + child: Row( + children: [ + // Location chip (city name). This is the only variable-width + // chip, so it is the one that gives way when space runs out. + Flexible( + child: _buildChip( + icon: locationIcon, + text: locationText, + color: locationColor, + ), + ), - // IATA code chip (only when in zone) - if (iataCode != null) ...[ - const SizedBox(width: 8), - _buildChip( - icon: Icons.flight, - text: iataCode, - color: Colors.blue, + // IATA code chip (only when in zone) + if (iataCode != null) ...[ + const SizedBox(width: 8), + _buildChip( + icon: Icons.flight, + text: iataCode, + color: Colors.blue, + ), + ], + ], ), - ], + ), - const Spacer(), + const SizedBox(width: 8), // Slots chip _buildChip( - icon: Icons.people_outline, - text: hasSlots ? '$slotsAvailable/$slotsMax Open' : '--', + icon: passiveOnly ? Icons.waves : Icons.people_outline, + text: passiveOnly + ? 'Flood Off' + : hasSlots + ? '$slotsAvailable/$slotsMax Open' + : '--', color: slotsColor, + onTap: passiveOnly ? () => _showFloodDisabledInfo(context) : null, ), ], ), @@ -1088,28 +1586,132 @@ class _ConnectionScreenState extends State with WidgetsBinding required IconData icon, required String text, required Color color, + VoidCallback? onTap, }) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: color.withValues(alpha: 0.4)), + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 4), + Flexible( + child: Text( + text, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ), + ], + ), ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 14, color: color), - const SizedBox(width: 4), - Text( - text, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: color, + ); + } + + /// Explains a flood-disabled region. + /// + /// The chip has room for eight characters, which cannot say that Trace Mode + /// still works, so the detail lives here. Layout mirrors + /// [_showAuthMethodInfo]; wording matches the region-veto dialog in + /// MainScaffold so users see one consistent explanation. + void _showFloodDisabledInfo(BuildContext context) { + final bodyStyle = TextStyle( + fontSize: 14, + height: 1.4, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ); + + showModalBottomSheet( + context: context, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header with close button + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 8, 12), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + border: + Border.all(color: Colors.blue.withValues(alpha: 0.4)), + ), + child: + const Icon(Icons.waves, color: Colors.blue, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Flood Traffic Disabled', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), ), - ), - ], + const Divider(height: 1), + // Content + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'This region has grown large enough that flood traffic ' + 'started hurting the mesh, so the regional mesh chose ' + 'to turn it off. Active and Hybrid modes are ' + 'unavailable across the whole MeshMapper region you ' + 'are in. It is not a capacity limit.', + style: bodyStyle, + ), + const SizedBox(height: 12), + Text( + 'Passive Mode (discovery requests and RX listening) and ' + 'Trace Mode still work, and they map a mesh this size ' + 'well. The app keeps logging everything your radio ' + 'hears.', + style: bodyStyle, + ), + const SizedBox(height: 12), + Text( + 'For more information, talk to your regional admin.', + style: bodyStyle, + ), + ], + ), + ), + ), + ], + ), ), ); } @@ -1123,7 +1725,8 @@ class _ConnectionScreenState extends State with WidgetsBinding required String message, Widget? action, }) { - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; // Use Center with CustomScrollView for both vertical centering and scroll capability return Center( @@ -1148,7 +1751,10 @@ class _ConnectionScreenState extends State with WidgetsBinding message, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), ), ), if (action != null) ...[ @@ -1164,107 +1770,100 @@ class _ConnectionScreenState extends State with WidgetsBinding Widget _buildDeviceList(BuildContext context, AppStateProvider appState) { // Offline mode bypasses both zone and maintenance checks - final canConnect = appState.offlineMode || (appState.inZone == true && !appState.maintenanceMode); + final canConnect = appState.offlineMode || + (appState.inZone == true && !appState.maintenanceMode); // Show maintenance message (takes priority over zone checks) if (appState.maintenanceMode && !appState.offlineMode) { - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.engineering, - size: 64, - color: Colors.orange.withValues(alpha: 0.7), - ), - const SizedBox(height: 16), - const Text( - 'Maintenance Mode', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - appState.maintenanceMessage ?? 'Service is temporarily unavailable.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade600, - ), - ), - const SizedBox(height: 32), - // Primary action: Enable Offline Mode - FilledButton.icon( - onPressed: () => appState.setOfflineMode(true), - icon: const Icon(Icons.cloud_off), - label: const Text('Enable Offline Mode'), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - ), - ), - if (appState.maintenanceUrl != null) ...[ - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: () => _launchMaintenanceUrl(appState.maintenanceUrl!), - icon: const Icon(Icons.open_in_new, size: 18), - label: const Text('More Info'), - ), - ], - const SizedBox(height: 32), - // Info note at bottom - 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: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.info_outline, size: 18, color: Colors.blue.shade700), - const SizedBox(width: 8), - Flexible( - child: Text( - 'Wardrive offline now, upload when service is restored.', - style: TextStyle(fontSize: 13, color: Colors.blue.shade700), - ), - ), - ], + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.engineering, + size: 64, + color: Colors.orange.withValues(alpha: 0.7), + ), + const SizedBox(height: 16), + const Text( + 'Maintenance Mode', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + appState.maintenanceMessage ?? + 'Service is temporarily unavailable.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 32), + // Primary action: Enable Offline Mode + FilledButton.icon( + onPressed: () => appState.setOfflineMode(true), + icon: const Icon(Icons.cloud_off), + label: const Text('Enable Offline Mode'), + style: FilledButton.styleFrom( + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + if (appState.maintenanceUrl != null) ...[ + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: () => + _launchMaintenanceUrl(appState.maintenanceUrl!), + icon: const Icon(Icons.open_in_new, size: 18), + label: const Text('More Info'), + ), + ], + const SizedBox(height: 32), + // Info note at bottom + 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: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.info_outline, + size: 18, color: Colors.blue.shade700), + const SizedBox(width: 8), + Flexible( + child: Text( + 'Wardrive offline now, upload when service is restored.', + style: TextStyle( + fontSize: 13, color: Colors.blue.shade700), ), ), ], ), ), - ), + ], ), - ], + ), ); } - // Show Bluetooth off message (takes priority over zone checks) - if (appState.isBluetoothOff) { - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.bluetooth_disabled, - iconColor: Colors.red.withValues(alpha: 0.7), - title: 'Bluetooth is Off', - message: 'Please enable Bluetooth to scan for MeshCore devices.', - ), - ), - ], + // Show Bluetooth off message (BLE transport only) + if (appState.selectedTransport == TransportType.ble && + appState.isBluetoothOff) { + return _buildMessageContent( + context: context, + icon: Icons.bluetooth_disabled, + iconColor: Colors.red.withValues(alpha: 0.7), + title: 'Bluetooth is Off', + message: 'Please enable Bluetooth to scan for MeshCore devices.', ); } @@ -1277,29 +1876,24 @@ class _ConnectionScreenState extends State with WidgetsBinding String message = 'Your geo zone is not on-boarded into MeshMapper.'; if (nearestName != null && distKmValue != null) { - final zoneDisplay = nearestCode != null ? '$nearestName ($nearestCode)' : nearestName; - final dist = formatKilometers(distKmValue, isImperial: appState.preferences.isImperial); + final zoneDisplay = + nearestCode != null ? '$nearestName ($nearestCode)' : nearestName; + final dist = formatKilometers(distKmValue, + isImperial: appState.preferences.isImperial); message += '\n\nNearest zone is $zoneDisplay, $dist away.'; } - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.public_off, - iconColor: Colors.orange.withValues(alpha: 0.7), - title: 'Region Not Available', - message: message, - action: OutlinedButton.icon( - onPressed: () => _launchOnboardingUrl(), - icon: const Icon(Icons.open_in_new, size: 18), - label: const Text('Request Region Onboarding'), - ), - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.public_off, + iconColor: Colors.orange.withValues(alpha: 0.7), + title: 'Region Not Available', + message: message, + action: OutlinedButton.icon( + onPressed: () => _launchOnboardingUrl(), + icon: const Icon(Icons.open_in_new, size: 18), + label: const Text('Request Region Onboarding'), + ), ); } @@ -1310,222 +1904,561 @@ class _ConnectionScreenState extends State with WidgetsBinding // Network error — show offline mode option (matches maintenance pattern) if (appState.zoneCheckErrorReason == 'network') { - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.wifi_off, + size: 64, + color: Colors.deepOrange.withValues(alpha: 0.7), + ), + const SizedBox(height: 16), + const Text( + 'No Internet Connection', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Unable to reach MeshMapper. Check your connection, or wardrive offline.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 8), + Text( + countdown > 0 + ? 'Retrying in ${countdown}s...' + : 'Retrying...', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade500, + ), + ), + const SizedBox(height: 32), + FilledButton.icon( + onPressed: () => appState.setOfflineMode(true), + icon: const Icon(Icons.cloud_off), + label: const Text('Enable Offline Mode'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 12), + ), + ), + const SizedBox(height: 32), + 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: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.wifi_off, - size: 64, - color: Colors.deepOrange.withValues(alpha: 0.7), - ), - const SizedBox(height: 16), - const Text( - 'No Internet Connection', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - 'Unable to reach MeshMapper. Check your connection, or wardrive offline.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade600, - ), - ), - const SizedBox(height: 8), - Text( - countdown > 0 - ? 'Retrying in ${countdown}s...' - : 'Retrying...', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13, - color: Colors.grey.shade500, - ), - ), - const SizedBox(height: 32), - FilledButton.icon( - onPressed: () => appState.setOfflineMode(true), - icon: const Icon(Icons.cloud_off), - label: const Text('Enable Offline Mode'), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - ), - ), - const SizedBox(height: 32), - 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: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.info_outline, size: 18, color: Colors.blue.shade700), - const SizedBox(width: 8), - Flexible( - child: Text( - 'Wardrive offline now, upload when service is restored.', - style: TextStyle(fontSize: 13, color: Colors.blue.shade700), - ), - ), - ], + Icon(Icons.info_outline, + size: 18, color: Colors.blue.shade700), + const SizedBox(width: 8), + Flexible( + child: Text( + 'Wardrive offline now, upload when service is restored.', + style: TextStyle( + fontSize: 13, color: Colors.blue.shade700), ), ), ], ), ), - ), + ], ), - ], + ), ); } - // Non-network errors — show simple error with countdown - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.cloud_off, - iconColor: Colors.orange.withValues(alpha: 0.7), - title: 'Zone Check Failed', - message: countdown > 0 - ? '${appState.zoneCheckError}\n\nChecking again in ${countdown}s...' - : '${appState.zoneCheckError}\n\nRetrying...', + // GPS errors — no auto-retry, show manual retry button. + // 'gps_stale' means the position timestamp doesn't line up with server + // time, which is almost always a wrong phone clock rather than a weak + // fix. Say so plainly, or people chase a GPS problem that isn't there. + if (appState.zoneCheckErrorReason == 'gps_inaccurate' || + appState.zoneCheckErrorReason == 'gps_stale') { + final isStale = appState.zoneCheckErrorReason == 'gps_stale'; + return _buildMessageContent( + context: context, + icon: isStale ? Icons.schedule : Icons.gps_off, + iconColor: Colors.orange.withValues(alpha: 0.7), + title: isStale ? 'Phone Clock Out of Sync' : 'GPS Accuracy Error', + message: isStale + ? "Your phone's clock is off, so your location looks out of date. " + 'This is a time problem, not a GPS problem.\n\n' + "Open your phone's Date & Time settings, turn on automatic " + 'date and time (and automatic time zone), then tap retry.' + : '${appState.zoneCheckError}\n\nTry moving to an area with better GPS signal, then tap retry.', + action: FilledButton.icon( + onPressed: () => appState.checkZoneStatus(), + icon: const Icon(Icons.refresh), + label: const Text('Retry Zone Check'), + style: FilledButton.styleFrom( + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 12), ), ), - ], + ); + } + + // Other non-network errors — show simple error with countdown + return _buildMessageContent( + context: context, + icon: Icons.cloud_off, + iconColor: Colors.orange.withValues(alpha: 0.7), + title: 'Zone Check Failed', + message: countdown > 0 + ? '${appState.zoneCheckError}\n\nChecking again in ${countdown}s...' + : '${appState.zoneCheckError}\n\nRetrying...', ); } // Zone check in progress or waiting for GPS - return Column( + return _buildMessageContent( + context: context, + icon: Icons.location_searching, + iconColor: Colors.blue.withValues(alpha: 0.7), + title: 'Checking MeshMapper Zone...', + message: 'Verifying your location with MeshMapper', + ); + } + + return _buildTransportContent(context, appState, canConnect); + } + + /// Transport selector buttons styled like the bottom bar. + Widget _buildTransportPicker( + BuildContext context, AppStateProvider appState) { + final available = _availableTransports(); + if (available.length <= 1) return const SizedBox.shrink(); + + final primary = Theme.of(context).colorScheme.primary; + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: primary.withValues(alpha: 0.3)), + ), + clipBehavior: Clip.antiAlias, + child: Row( children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.location_searching, - iconColor: Colors.blue.withValues(alpha: 0.7), - title: 'Checking MeshMapper Zone...', - message: 'Verifying your location with MeshMapper', + for (final transport in available) + Expanded( + child: _buildTransportSegment( + context, + appState, + transport, + isSelected: appState.selectedTransport == transport, + ), ), - ), ], - ); + ), + ); + } + + Widget _buildTransportSegment( + BuildContext context, AppStateProvider appState, TransportType type, + {required bool isSelected}) { + const meta = { + TransportType.ble: (Icons.bluetooth, 'BLE'), + TransportType.tcp: (Icons.lan, 'TCP'), + TransportType.usbSerial: (Icons.usb, 'USB'), + }; + final (icon, label) = meta[type]!; + final primary = Theme.of(context).colorScheme.primary; + final color = isSelected ? primary : Colors.grey; + + return Material( + color: isSelected ? primary.withValues(alpha: 0.2) : Colors.transparent, + child: InkWell( + onTap: () => appState.setSelectedTransport(type), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ], + ), + ), + ), + ); + } + + /// Available transports based on platform. + /// BLE: all platforms. TCP: mobile only. USB: Android and Web. + List _availableTransports() { + final transports = [TransportType.ble]; + if (!kIsWeb) { + transports.add(TransportType.tcp); + if (Platform.isAndroid) { + transports.add(TransportType.usbSerial); + } + } else { + transports.add(TransportType.usbSerial); + } + return transports; + } + + /// Routes to the correct transport-specific content. + Widget _buildTransportContent( + BuildContext context, AppStateProvider appState, bool canConnect) { + switch (appState.selectedTransport) { + case TransportType.ble: + return _buildBleContent(context, appState, canConnect); + case TransportType.tcp: + return _buildTcpContent(context, appState, canConnect); + case TransportType.usbSerial: + return _buildUsbContent(context, appState, canConnect); } + } + /// BLE transport content — scanning, device list, remembered device. + Widget _buildBleContent( + BuildContext context, AppStateProvider appState, bool canConnect) { if (appState.isScanning) { return Column( children: [ - _buildZoneStatusBar(context, appState), const LinearProgressIndicator(), - Expanded(child: _buildDeviceListView(context, appState, canConnect: canConnect)), + Expanded( + child: _buildDeviceListView(context, appState, + canConnect: canConnect)), ], ); } if (appState.discoveredDevices.isEmpty) { - // Show remembered device option if available (mobile only) final remembered = appState.rememberedDevice; - if (!kIsWeb && remembered != null) { - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded(child: _buildRememberedDeviceView(context, appState, remembered, canConnect: canConnect)), - ], - ); + if (!kIsWeb && + remembered != null && + remembered.transportType == TransportType.ble) { + return _buildRememberedDeviceView(context, appState, remembered, + canConnect: canConnect); } - // Show GPS disabled message when location services are off if (appState.gpsStatus == GpsStatus.disabled) { - // iOS doesn't allow opening Location Services directly, so no button on iOS final isIOS = !kIsWeb && Platform.isIOS; - - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.gps_off, - iconColor: Colors.red.withValues(alpha: 0.7), - title: 'Location Services Disabled', - message: 'Please enable Location Services to verify you\'re in an allowed zone.', - action: isIOS - ? null - : ElevatedButton.icon( - onPressed: () => Geolocator.openLocationSettings(), - icon: const Icon(Icons.settings), - label: const Text('Open Location Settings'), - ), - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.gps_off, + iconColor: Colors.red.withValues(alpha: 0.7), + title: 'Location Services Disabled', + message: + 'Please enable Location Services to verify you\'re in an allowed zone.', + action: isIOS + ? null + : ElevatedButton.icon( + onPressed: () => Geolocator.openLocationSettings(), + icon: const Icon(Icons.settings), + label: const Text('Open Location Settings'), + ), ); } - // Show GPS permission required message when permissions are denied if (appState.gpsStatus == GpsStatus.permissionDenied) { - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.location_off, - iconColor: Colors.orange.withValues(alpha: 0.7), - title: 'GPS Permission Required', - message: 'Location access is needed to verify you\'re in an allowed zone.', - action: ElevatedButton.icon( - onPressed: () => _requestLocationPermission(appState), - icon: const Icon(Icons.location_on), - label: const Text('Enable Location'), - ), - ), - ), - ], + return _buildMessageContent( + context: context, + icon: Icons.location_off, + iconColor: Colors.orange.withValues(alpha: 0.7), + title: 'GPS Permission Required', + message: + 'Location access is needed to verify you\'re in an allowed zone.', + action: ElevatedButton.icon( + onPressed: () => _requestLocationPermission(appState), + icon: const Icon(Icons.location_on), + label: const Text('Enable Location'), + ), ); } - return Column( + return _buildMessageContent( + context: context, + icon: Icons.bluetooth_searching, + iconColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), + title: 'No devices found', + message: 'Tap Scan to search for MeshCore devices', + ); + } + + return _buildDeviceListView(context, appState, canConnect: canConnect); + } + + /// TCP transport content — host/port entry + saved connections. + Widget _buildTcpContent( + BuildContext context, AppStateProvider appState, bool canConnect) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildZoneStatusBar(context, appState), - Expanded( - child: _buildMessageContent( - context: context, - icon: Icons.bluetooth_searching, - iconColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), - title: 'No devices found', - message: 'Tap Scan to search for MeshCore devices', + // Host/port input + TextField( + controller: _tcpHostController, + decoration: const InputDecoration( + labelText: 'Host', + hintText: 'IP address or hostname', + prefixIcon: Icon(Icons.dns_outlined), + border: OutlineInputBorder(), ), + keyboardType: TextInputType.url, + textInputAction: TextInputAction.next, + enabled: !_tcpConnecting, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 12), + TextField( + controller: _tcpPortController, + decoration: const InputDecoration( + labelText: 'Port', + hintText: '5000', + prefixIcon: Icon(Icons.numbers), + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + enabled: !_tcpConnecting, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 16), + // Saved connections + FutureBuilder>( + future: _savedTcpFuture ??= TcpService.getSavedConnections(), + builder: (context, snapshot) { + final saved = snapshot.data; + if (saved == null || saved.isEmpty) { + return const SizedBox.shrink(); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Saved Connections', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 8), + ...saved.map((conn) => + _buildSavedTcpTile(context, appState, conn, canConnect)), + ], + ); + }, ), ], - ); + ), + ); + } + + void _connectTcp(AppStateProvider appState) async { + final host = _tcpHostController.text.trim(); + final portStr = _tcpPortController.text.trim(); + if (host.isEmpty) return; + final port = int.tryParse(portStr) ?? 5000; + + setState(() => _tcpConnecting = true); + try { + await appState.connectViaTcp(host, port); + } finally { + if (mounted) { + setState(() { + _tcpConnecting = false; + _savedTcpFuture = TcpService.getSavedConnections(); + }); + } + } + } + + Widget _buildSavedTcpTile(BuildContext context, AppStateProvider appState, + SavedTcpConnection conn, bool canConnect) { + final showName = !conn.name.startsWith('TCP '); + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + showName ? conn.name : conn.host, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + '${conn.host}:${conn.port}', + style: TextStyle(fontSize: 13, color: Colors.grey.shade600), + ), + ], + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.delete_outline, size: 20), + constraints: const BoxConstraints(), + padding: const EdgeInsets.all(8), + onPressed: () async { + await TcpService.deleteConnection(conn.id); + if (mounted) { + final future = TcpService.getSavedConnections(); + setState(() { + _savedTcpFuture = future; + }); + } + }, + ), + const SizedBox(width: 4), + FilledButton( + onPressed: canConnect && !_tcpConnecting + ? () { + _tcpHostController.text = conn.host; + _tcpPortController.text = conn.port.toString(); + _connectTcp(appState); + } + : null, + child: const Text('Reconnect'), + ), + ], + ), + ), + ); + } + + /// USB Serial transport content. + Widget _buildUsbContent( + BuildContext context, AppStateProvider appState, bool canConnect) { + if (kIsWeb) { + return _buildWebSerialContent(context, appState, canConnect); } + return _buildAndroidSerialContent(context, appState, canConnect); + } - return Column( - children: [ - _buildZoneStatusBar(context, appState), - Expanded(child: _buildDeviceListView(context, appState, canConnect: canConnect)), - ], + Widget _buildAndroidSerialContent( + BuildContext context, AppStateProvider appState, bool canConnect) { + return FutureBuilder>>( + future: _usbDevicesFuture ??= AndroidSerialService.getAvailablePorts(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + final devices = snapshot.data ?? []; + if (devices.isEmpty) { + return _buildMessageContent( + context: context, + icon: Icons.usb_off, + iconColor: Colors.grey.withValues(alpha: 0.5), + title: 'No USB Devices', + message: 'Connect a MeshCore device via USB OTG and tap Refresh.', + action: FilledButton.icon( + onPressed: () => setState(() { + _usbDevicesFuture = AndroidSerialService.getAvailablePorts(); + }), + icon: const Icon(Icons.refresh), + label: const Text('Refresh'), + ), + ); + } + + return ListView.builder( + itemCount: devices.length, + itemBuilder: (context, index) { + final device = devices[index]; + final vid = device['vid'] as int? ?? 0; + final pid = device['pid'] as int? ?? 0; + return ListTile( + leading: const Icon(Icons.usb), + title: Text(device['productName'] as String? ?? 'USB Device'), + subtitle: Text( + 'VID: ${vid.toRadixString(16)} PID: ${pid.toRadixString(16)}'), + enabled: canConnect, + onTap: canConnect ? () => appState.connectViaUsb(device) : null, + ); + }, + ); + }, ); } + Widget _buildWebSerialContent( + BuildContext context, AppStateProvider appState, bool canConnect) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.usb, + size: 64, + color: + Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), + ), + const SizedBox(height: 16), + Text( + 'USB Serial', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Connect a MeshCore device via USB and tap Connect below. ' + 'Your browser will show a port picker.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey.shade600), + ), + ], + ), + ), + ); + } + + void _connectWebSerial(AppStateProvider appState) async { + try { + final transport = await openWebSerialTransport(); + await appState.connectWithTransport( + transport, + deviceId: 'webserial', + deviceName: 'USB Serial (Web)', + serialPortPath: 'webserial', + ); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('USB Serial error: $e')), + ); + } + } + } + void _launchOnboardingUrl() async { final uri = Uri.parse('https://meshmapper.net/?onboarding'); try { @@ -1550,7 +2483,8 @@ class _ConnectionScreenState extends State with WidgetsBinding RememberedDevice remembered, { bool canConnect = true, }) { - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; return SingleChildScrollView( child: Center( @@ -1580,7 +2514,9 @@ class _ConnectionScreenState extends State with WidgetsBinding ), SizedBox(height: isLandscape ? 12 : 24), ElevatedButton.icon( - onPressed: canConnect ? () => appState.reconnectToRememberedDevice() : null, + onPressed: canConnect + ? () => appState.reconnectToRememberedDevice() + : null, icon: const Icon(Icons.bluetooth_connected), label: Text(canConnect ? 'Reconnect' @@ -1627,7 +2563,8 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } - Widget _buildDeviceListView(BuildContext context, AppStateProvider appState, {bool canConnect = true}) { + Widget _buildDeviceListView(BuildContext context, AppStateProvider appState, + {bool canConnect = true}) { return ListView.builder( itemCount: appState.discoveredDevices.length, itemBuilder: (context, index) { @@ -1706,7 +2643,9 @@ class _ConnectionScreenState extends State with WidgetsBinding ), const SizedBox(height: 12), const Text( - 'Your radio will remain in multi-byte mode until you change it.', + 'On a normal disconnect, the app restores your radio to its ' + 'original path setting. If the connection drops unexpectedly, ' + 'you may need to switch it back yourself in Settings.', style: TextStyle(fontSize: 13, color: Colors.amber), ), ], @@ -1755,9 +2694,8 @@ class _DeviceListTile extends StatelessWidget { device.id, style: TextStyle(color: enabled ? null : Colors.grey), ), - trailing: device.rssi != null - ? _buildRssiChip(device.rssi!, enabled) - : null, + trailing: + device.rssi != null ? _buildRssiChip(device.rssi!, enabled) : null, enabled: enabled, onTap: onTap, ); diff --git a/lib/screens/graph_screen.dart b/lib/screens/graph_screen.dart index b9a4141d..6fe8e08f 100644 --- a/lib/screens/graph_screen.dart +++ b/lib/screens/graph_screen.dart @@ -7,7 +7,7 @@ import '../models/noise_floor_session.dart'; import '../providers/app_state_provider.dart'; import '../widgets/noise_floor_chart.dart'; -/// Screen showing noise floor session history and graph popup +/// Screen showing session history with options to view on map or noise floor graph class GraphScreen extends StatelessWidget { const GraphScreen({super.key}); @@ -19,27 +19,28 @@ class GraphScreen extends StatelessWidget { return Scaffold( appBar: AppBar( - title: const Text('Noise Floor History'), + toolbarHeight: 40, + title: const Text('Session History', style: TextStyle(fontSize: 18)), automaticallyImplyLeading: false, actions: [ if (sessions.isNotEmpty) IconButton( - icon: const Icon(Icons.delete_outline), + icon: const Icon(Icons.delete_outline, size: 20), onPressed: () => _confirmClearSessions(context, appState), tooltip: 'Clear all sessions', ), ], ), - body: _buildBody(context, currentSession, sessions), + body: _buildBody(context, appState, currentSession, sessions), ); } Widget _buildBody( BuildContext context, + AppStateProvider appState, NoiseFloorSession? currentSession, List sessions, ) { - // Show empty state if no sessions if (currentSession == null && sessions.isEmpty) { return Center( child: Padding( @@ -48,7 +49,7 @@ class GraphScreen extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - Icons.show_chart, + Icons.history, size: 64, color: Theme.of(context).colorScheme.onSurfaceVariant, ), @@ -59,7 +60,7 @@ class GraphScreen extends StatelessWidget { ), const SizedBox(height: 8), Text( - 'Enable Active or Passive Mode to start recording noise floor data.', + 'Enable Active or Passive Mode to start recording session data.', textAlign: TextAlign.center, style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, @@ -73,30 +74,39 @@ class GraphScreen extends StatelessWidget { return ListView( children: [ - // Current active session (if recording) if (currentSession != null) ...[ - _SessionListTile( + _SessionCard( session: currentSession, isActive: true, - onTap: () => _openFullScreenGraph(context, currentSession, isLive: true), + onViewGraph: () => + _openFullScreenGraph(context, currentSession, isLive: true), + onViewMap: null, ), - if (sessions.isNotEmpty) const Divider(), + if (sessions.isNotEmpty) const SizedBox(height: 4), ], - - // Stored sessions (last 10) - ...sessions.map((session) => _SessionListTile( + ...sessions.map((session) => _SessionCard( session: session, isActive: false, - onTap: () => _openFullScreenGraph(context, session), + onViewGraph: () => _openFullScreenGraph(context, session), + onViewMap: _sessionHasGpsMarkers(session) + ? () => appState.viewHistorySessionOnMap(session) + : null, )), ], ); } - void _openFullScreenGraph(BuildContext context, NoiseFloorSession session, {bool isLive = false}) { + bool _sessionHasGpsMarkers(NoiseFloorSession session) { + return session.markers + .any((m) => m.latitude != null && m.longitude != null); + } + + void _openFullScreenGraph(BuildContext context, NoiseFloorSession session, + {bool isLive = false}) { Navigator.of(context).push( MaterialPageRoute( - builder: (context) => _FullScreenGraphPage(session: session, isLive: isLive), + builder: (context) => + _FullScreenGraphPage(session: session, isLive: isLive), ), ); } @@ -106,7 +116,8 @@ class GraphScreen extends StatelessWidget { context: context, builder: (context) => AlertDialog( title: const Text('Clear All Sessions?'), - content: const Text('This will delete all saved noise floor session graphs. The current active session will not be affected.'), + content: const Text( + 'This will delete all saved session history. The current active session will not be affected.'), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -125,6 +136,162 @@ class GraphScreen extends StatelessWidget { } } +/// Card showing a session with action buttons +class _SessionCard extends StatelessWidget { + final NoiseFloorSession session; + final bool isActive; + final VoidCallback onViewGraph; + final VoidCallback? onViewMap; + + const _SessionCard({ + required this.session, + required this.isActive, + required this.onViewGraph, + required this.onViewMap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row: mode icon, mode name, live badge, time + Row( + children: [ + Icon( + _modeIcon, + size: 20, + color: isActive ? Colors.green : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + session.modeDisplay, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + if (isActive) ...[ + const SizedBox(width: 8), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.green), + ), + child: const Text( + 'LIVE', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.green, + ), + ), + ), + ], + const Spacer(), + Text( + _formatDateTime(session.startTime), + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 6), + // Stats row + Text( + '${session.durationDisplay} duration | ' + '${session.samples.length} samples | ' + '${session.markers.length} events', + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 10), + // Action buttons row + Row( + children: [ + Expanded( + child: _ActionButton( + icon: Icons.map_outlined, + label: 'View on Map', + onPressed: onViewMap, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _ActionButton( + icon: Icons.show_chart, + label: 'Noise Floor', + onPressed: onViewGraph, + ), + ), + ], + ), + ], + ), + ), + ); + } + + IconData get _modeIcon => switch (session.mode) { + 'active' => Icons.send, + 'hybrid' => Icons.swap_horiz, + 'targeted' => Icons.track_changes, + _ => Icons.hearing, + }; + + String _formatDateTime(DateTime dt) { + return '${dt.month}/${dt.day} ${dt.hour}:${dt.minute.toString().padLeft(2, '0')}'; + } +} + +/// Styled action button for session cards +class _ActionButton extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback? onPressed; + + const _ActionButton({ + required this.icon, + required this.label, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return OutlinedButton.icon( + onPressed: onPressed, + icon: Icon(icon, size: 16), + label: Text(label, style: const TextStyle(fontSize: 12)), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + minimumSize: const Size(0, 34), + foregroundColor: + onPressed != null ? colorScheme.primary : colorScheme.outline, + side: BorderSide( + color: onPressed != null + ? colorScheme.outline + : colorScheme.outline.withValues(alpha: 0.4), + ), + ), + ); + } +} + /// Full-screen graph page with pinch-to-zoom and pan class _FullScreenGraphPage extends StatefulWidget { final NoiseFloorSession session; @@ -148,13 +315,13 @@ class _FullScreenGraphPageState extends State<_FullScreenGraphPage> { _session = widget.session; if (widget.isLive) { _liveTimer = Timer.periodic(const Duration(seconds: 2), (_) { - final current = context.read().currentNoiseFloorSession; + final current = + context.read().currentNoiseFloorSession; if (current != null) { setState(() { _session = current; }); } else { - // Session ended while viewing _liveTimer?.cancel(); _liveTimer = null; setState(() { @@ -207,7 +374,6 @@ class _FullScreenGraphPageState extends State<_FullScreenGraphPage> { onPressed: () => Navigator.pop(context), ), actions: [ - // Reset zoom button IconButton( icon: const Icon(Icons.zoom_out_map), tooltip: 'Reset zoom', @@ -220,7 +386,6 @@ class _FullScreenGraphPageState extends State<_FullScreenGraphPage> { body: SafeArea( child: Column( children: [ - // Session info header Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( @@ -247,7 +412,6 @@ class _FullScreenGraphPageState extends State<_FullScreenGraphPage> { ), ), const Divider(height: 1), - // Hint text Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Text( @@ -259,7 +423,6 @@ class _FullScreenGraphPageState extends State<_FullScreenGraphPage> { ), ), ), - // Chart Expanded( child: Padding( padding: const EdgeInsets.fromLTRB(8, 0, 16, 8), @@ -280,58 +443,3 @@ class _FullScreenGraphPageState extends State<_FullScreenGraphPage> { return '${dt.month}/${dt.day} ${dt.hour}:${dt.minute.toString().padLeft(2, '0')}'; } } - -/// List tile showing a session summary -class _SessionListTile extends StatelessWidget { - final NoiseFloorSession session; - final bool isActive; - final VoidCallback onTap; - - const _SessionListTile({ - required this.session, - required this.isActive, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return ListTile( - leading: Icon( - session.mode == 'active' ? Icons.send : Icons.hearing, - color: isActive ? Colors.green : null, - ), - title: Text(session.modeDisplay), - subtitle: Text( - '${_formatDateTime(session.startTime)} | ${session.durationDisplay} | ' - '${session.samples.length} samples, ${session.markers.length} events', - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - trailing: isActive - ? Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.green), - ), - child: const Text( - 'LIVE', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - color: Colors.green, - ), - ), - ) - : const Icon(Icons.chevron_right), - onTap: onTap, - ); - } - - String _formatDateTime(DateTime dt) { - return '${dt.month}/${dt.day} ${dt.hour}:${dt.minute.toString().padLeft(2, '0')}'; - } -} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 07711e64..a85230e4 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../models/connection_state.dart'; import '../providers/app_state_provider.dart'; import '../utils/distance_formatter.dart'; +import '../utils/ping_colors.dart'; import '../widgets/connection_panel.dart'; import '../widgets/map_widget.dart'; import '../widgets/ping_controls.dart'; @@ -28,6 +29,54 @@ class _HomeScreenState extends State { /// Landscape side panel width (220px gives more room for controls) static const double _landscapePanelWidth = 220.0; + // ── Map Selector memoization (overheating fix) ──────────────────────────── + // HomeScreen.build() runs context.watch, so it rebuilds on EVERY provider + // notify (incl. the 2Hz GPS notify). An inline `Selector(...)` is a NEW widget + // instance each build, and provider's Selector invalidates its cache whenever + // `oldWidget != widget` (selector.dart:77) — so a fresh instance forces the + // cached MapWidget to rebuild BEFORE the value comparison runs, relayouting the + // iOS platform view (~24ms) every GPS tick. Returning the SAME Selector instance + // (identity stable) makes Flutter short-circuit the child and lets the value + // comparison actually gate the map. The instance is keyed only on the State + // fields its closures capture (isLandscape / _isControlsMinimized / + // _mapControlsExpanded); provider values (mapRevision/focus/history) are read + // live inside the closures and handled by the Selector's value comparison. + Widget? _cachedMapSelector; + ({bool landscape, bool minimized, bool ctrlExpanded})? _cachedMapSelectorKey; + + Widget _buildMapSelector(bool isLandscape) { + final key = ( + landscape: isLandscape, + minimized: _isControlsMinimized, + ctrlExpanded: _mapControlsExpanded, + ); + if (_cachedMapSelector != null && _cachedMapSelectorKey == key) { + return _cachedMapSelector!; + } + _cachedMapSelectorKey = key; + _cachedMapSelector = Selector( + selector: (_, p) => ( + rev: p.mapRevision, + focus: p.isFocusModeActive, + history: p.viewingHistorySession, + padH: isLandscape || + p.isFocusModeActive || + p.viewingHistorySession || + p.infoPopupMinimized + ? 0.0 + : _getControlPanelHeight(), + ctrl: isLandscape ? _mapControlsExpanded : null, + ), + builder: (_, s, __) => MapWidget( + bottomPaddingPixels: s.padH, + mapControlsExpanded: s.ctrl, + onMapControlsToggle: isLandscape ? _toggleMapControls : null, + ), + ); + return _cachedMapSelector!; + } + /// Toggle control panel in landscape mode (closes map controls if open) void _toggleControlPanel() { setState(() { @@ -67,11 +116,11 @@ class _HomeScreenState extends State { return _isControlsMinimized ? 60 : 320; } - @override Widget build(BuildContext context) { final appState = context.watch(); - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; // In landscape: no AppBar, everything on map overlays if (isLandscape) { @@ -114,6 +163,7 @@ class _HomeScreenState extends State { body: _buildLayout(appState, isLandscape: false), floatingActionButton: !_showControlPanel ? FloatingActionButton.extended( + heroTag: null, onPressed: () => setState(() => _showControlPanel = true), icon: const Icon(Icons.tune), label: const Text('Controls'), @@ -147,7 +197,8 @@ class _HomeScreenState extends State { } /// Stats row for AppBar/floating status bar (matches StatusBar exactly) - Widget _buildAppBarStats(AppStateProvider appState, {bool withTapHandlers = false}) { + Widget _buildAppBarStats(AppStateProvider appState, + {bool withTapHandlers = false}) { return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -155,7 +206,7 @@ class _HomeScreenState extends State { _buildAppBarStatChip( Icons.arrow_upward, appState.pingStats.txCount, - Colors.green, + PingColors.txSuccess, onTap: withTapHandlers ? () => _showInfoPopup('tx', appState) : null, ), const SizedBox(width: 8), @@ -163,7 +214,7 @@ class _HomeScreenState extends State { _buildAppBarStatChip( Icons.arrow_downward, appState.pingStats.rxCount, - Colors.blue, + PingColors.rx, onTap: withTapHandlers ? () => _showInfoPopup('rx', appState) : null, ), const SizedBox(width: 8), @@ -171,8 +222,18 @@ class _HomeScreenState extends State { _buildAppBarStatChip( Icons.radar, appState.pingStats.discCount, - const Color(0xFF7B68EE), - onTap: withTapHandlers ? () => _showInfoPopup('disc', appState) : null, + PingColors.discSuccess, + onTap: + withTapHandlers ? () => _showInfoPopup('disc', appState) : null, + ), + const SizedBox(width: 8), + // Trace count + _buildAppBarStatChip( + Icons.route, + appState.pingStats.traceCount, + PingColors.traceSuccess, + onTap: + withTapHandlers ? () => _showInfoPopup('trace', appState) : null, ), const SizedBox(width: 8), // Upload count @@ -180,14 +241,16 @@ class _HomeScreenState extends State { Icons.cloud_done, appState.pingStats.successfulUploads, Colors.teal.shade400, - onTap: withTapHandlers ? () => _showInfoPopup('upload', appState) : null, + onTap: + withTapHandlers ? () => _showInfoPopup('upload', appState) : null, ), ], ); } /// Stat chip for AppBar (same style as StatusBar) - Widget _buildAppBarStatChip(IconData icon, int value, Color color, {VoidCallback? onTap}) { + Widget _buildAppBarStatChip(IconData icon, int value, Color color, + {VoidCallback? onTap}) { final chip = Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( @@ -230,7 +293,8 @@ class _HomeScreenState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Padding( - padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 20, 20, 20 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -291,48 +355,113 @@ class _HomeScreenState extends State { } /// Get content for the popup based on ID - (String, String, IconData, Color) _getPopupContent(String id, AppStateProvider appState) { + (String, String, IconData, Color) _getPopupContent( + String id, AppStateProvider appState) { switch (id) { case 'gps': if (appState.offlineMode) { - return ('Offline Mode Active', 'Pings are saved locally. Zone detection is paused until you go back online.', Icons.flight, Colors.grey); + return ( + 'Offline Mode Active', + 'Pings are saved locally. Zone detection is paused until you go back online.', + Icons.flight, + Colors.grey + ); } if (appState.inZone == true && appState.zoneCode != null) { if (!appState.isConnected) { - return ('${appState.zoneName ?? appState.zoneCode} Zone', + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', 'You\'re in an authorized zone. Connect to a device to start wardriving.', - Icons.flight, Colors.grey); + Icons.flight, + Colors.grey + ); + } + // Region veto comes first: a flood-disabled region reports 0 TX slots, + // which would otherwise read as "zone full" (red) instead of a + // deliberate regional setting (blue). + if (appState.floodDisabled) { + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', + 'Your regional admin has turned off flood traffic here, so Active and Hybrid modes are unavailable. The zone is not full. Passive Mode and Trace Mode still work.', + Icons.flight, + Colors.blue + ); } if (!appState.txAllowed) { - return ('${appState.zoneName ?? appState.zoneCode} Zone', + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', 'You\'re in an authorized zone. However, the zone is at Active Wardrive capacity. You can still wardrive, but only Passive Mode is allowed.', - Icons.flight, Colors.red); + Icons.flight, + Colors.red + ); } - return ('${appState.zoneName ?? appState.zoneCode} Zone', + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', 'You\'re in an active zone with ${appState.zoneSlotsAvailable ?? "?"} TX slots available. Ready to wardrive!', - Icons.flight, Colors.green); + Icons.flight, + Colors.green + ); } if (appState.inZone == false) { final nearest = appState.nearestZoneName ?? 'Unknown'; final distKm = appState.nearestZoneDistanceKm; final dist = distKm != null - ? formatKilometers(distKm, isImperial: appState.preferences.isImperial) + ? formatKilometers(distKm, + isImperial: appState.preferences.isImperial) : '?'; - return ('Outside Coverage Area', 'Nearest zone is $nearest, $dist away. Enter a zone to start wardriving.', Icons.flight, Colors.orange); + return ( + 'Outside Coverage Area', + 'Nearest zone is $nearest, $dist away. Enter a zone to start wardriving.', + Icons.flight, + Colors.orange + ); } - return ('Locating...', 'Acquiring GPS signal and checking your zone status.', Icons.gps_not_fixed, Colors.blue); + return ( + 'Locating...', + 'Acquiring GPS signal and checking your zone status.', + Icons.gps_not_fixed, + Colors.blue + ); case 'tx': - return ('TX Packets', 'TX packets that have been sent out. These are messages to the #wardriving channel.', Icons.arrow_upward, Colors.green); + return ( + 'TX Packets', + 'TX packets that have been sent out. These are messages to the #wardriving channel.', + Icons.arrow_upward, + PingColors.txSuccess + ); case 'rx': - return ('RX Packets', 'RX packets that we have heard from the mesh. These were not initiated by us.', Icons.arrow_downward, Colors.blue); + return ( + 'RX Packets', + 'RX packets that we have heard from the mesh. These were not initiated by us.', + Icons.arrow_downward, + PingColors.rx + ); case 'disc': - return ('Discovery Requests', 'Discovery request packets we have sent out.', Icons.radar, const Color(0xFF7B68EE)); + return ( + 'Discovery Requests', + 'Discovery request packets we have sent out.', + Icons.radar, + PingColors.discSuccess + ); + + case 'trace': + return ( + 'Trace Responses', + 'Trace path requests that received a response from the target repeater.', + Icons.route, + PingColors.traceSuccess + ); case 'upload': - return ('Uploaded', 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', Icons.cloud_done, Colors.teal); + return ( + 'Uploaded', + 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', + Icons.cloud_done, + Colors.teal + ); default: return ('Info', '', Icons.info, Colors.grey); @@ -353,17 +482,24 @@ class _HomeScreenState extends State { children: [ if (!isLandscape) const StatusBar() else const SizedBox.shrink(), Expanded( - child: MapWidget( - bottomPaddingPixels: isLandscape ? 0 : _getControlPanelHeight(), - mapControlsExpanded: isLandscape ? _mapControlsExpanded : null, - onMapControlsToggle: isLandscape ? _toggleMapControls : null, - ), + // The MapLibre map is the most expensive subtree in the app. + // Isolate it from the provider's high-frequency UI-only notifies + // (noise floor, battery, live stats) and the dense-mesh RX-pin + // storm by rebuilding it ONLY when a map-relevant value changes: + // AppStateProvider.mapRevision (markers/position/history) or the + // map's own layout inputs (focus/history/padding/controls). The + // Selector is MEMOIZED (_buildMapSelector) so its widget identity + // is stable across HomeScreen's per-notify rebuilds — otherwise a + // fresh Selector instance defeats its own cache (selector.dart:77, + // `oldWidget != widget`) and the map rebuilds every GPS tick. This + // is the core of the overheating fix. + child: _buildMapSelector(isLandscape), ), ], ), - // Landscape: floating status bar overlay - if (isLandscape) + // Landscape: floating status bar overlay (hidden during history view) + if (isLandscape && !appState.viewingHistorySession) Positioned( top: 16, left: leftInset + 72, @@ -384,8 +520,34 @@ class _HomeScreenState extends State { ), ), - // Portrait: bottom control panel - if (!isLandscape) + // Zone grace period overlay (both orientations) + if (appState.isInZoneGracePeriod) + Positioned.fill( + child: Container( + color: Colors.black54, + child: Center( + child: _buildZoneGraceOverlay(appState), + ), + ), + ), + + // Zone transfer overlay (both orientations) + if (appState.isZoneTransferInProgress) + Positioned.fill( + child: Container( + color: Colors.black54, + child: Center( + child: _buildZoneTransferOverlay(appState), + ), + ), + ), + + // Portrait: bottom control panel (hidden during focus mode, history view, + // and while a cell/repeater popup is minimized to a pill) + if (!isLandscape && + !appState.isFocusModeActive && + !appState.viewingHistorySession && + !appState.infoPopupMinimized) Positioned( bottom: 0, left: 0, @@ -395,18 +557,43 @@ class _HomeScreenState extends State { : _buildControlPanel(), ), - // Landscape: side control panel or FAB - if (isLandscape && _showControlPanel) + // Landscape: full side control panel, bottom-left (hidden during focus + // mode, history view, and while a cell/repeater popup is minimized) + if (isLandscape && + _showControlPanel && + !_isControlsMinimized && + !appState.isFocusModeActive && + !appState.viewingHistorySession && + !appState.infoPopupMinimized) Positioned( bottom: 16, left: leftInset, child: _buildLandscapeControlPanel(appState), ), - if (isLandscape && !_showControlPanel) + // Landscape: minimized control bar, centered along the bottom of the map + // (see #329) + if (isLandscape && + _showControlPanel && + _isControlsMinimized && + !appState.isFocusModeActive && + !appState.viewingHistorySession && + !appState.infoPopupMinimized) + Positioned( + bottom: 16, + left: 0, + right: 0, + child: Center(child: _buildLandscapeCompactControlPanel()), + ), + if (isLandscape && + !_showControlPanel && + !appState.isFocusModeActive && + !appState.viewingHistorySession && + !appState.infoPopupMinimized) Positioned( bottom: 16, left: leftInset, child: FloatingActionButton.small( + heroTag: null, onPressed: _toggleControlPanel, child: const Icon(Icons.tune), ), @@ -542,7 +729,21 @@ class _HomeScreenState extends State { borderRadius: BorderRadius.circular(20), child: Padding( padding: const EdgeInsets.all(8), - child: Icon(Icons.help_outline, size: 22, color: Colors.grey.shade400), + child: Icon(Icons.help_outline, + size: 22, color: Colors.grey.shade400), + ), + ), + ), + // Minimize button - collapse to compact strip (see #329) + Material( + color: Colors.transparent, + child: InkWell( + onTap: () => setState(() => _isControlsMinimized = true), + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon(Icons.close_fullscreen, + size: 22, color: Colors.grey.shade400), ), ), ), @@ -554,7 +755,8 @@ class _HomeScreenState extends State { borderRadius: BorderRadius.circular(20), child: Padding( padding: const EdgeInsets.all(8), - child: Icon(Icons.close, size: 22, color: Colors.grey.shade400), + child: Icon(Icons.close, + size: 22, color: Colors.grey.shade400), ), ), ), @@ -575,6 +777,59 @@ class _HomeScreenState extends State { ); } + /// Build minimized landscape control panel — a compact strip mirroring the + /// portrait compact panel (CompactPingControls + expand button), sized for the + /// landscape side overlay. Reuses the shared `_isControlsMinimized` flag (see + /// #329). Status/zone stays visible via the floating status bar, so this only + /// needs the ping controls + an expand affordance. + Widget _buildLandscapeCompactControlPanel() { + return Container( + width: _landscapePanelWidth, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.25), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + // Compact controls (expands to fill available space) + const Expanded( + child: CompactPingControls(), + ), + // Vertical divider + Container( + height: 24, + width: 1, + margin: const EdgeInsets.symmetric(horizontal: 6), + color: Colors.grey.withValues(alpha: 0.3), + ), + // Expand button - restores the full landscape panel + GestureDetector( + onTap: () => setState(() => _isControlsMinimized = false), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon( + Icons.open_in_full, + size: 20, + color: Colors.grey.shade500, + ), + ), + ), + ], + ), + ), + ); + } + /// Compact zone chip for landscape panel Widget _buildZoneChip(AppStateProvider appState) { IconData icon; @@ -593,7 +848,11 @@ class _HomeScreenState extends State { if (appState.inZone == true && appState.zoneCode != null) { icon = Icons.flight; color = appState.isConnected - ? (appState.txAllowed ? Colors.green : Colors.red) + ? (appState.floodDisabled + ? Colors.blue // Region veto: passive only, not an error + : appState.txAllowed + ? Colors.green + : Colors.red) : Colors.grey; text = appState.zoneCode!; } else if (appState.inZone == false) { @@ -642,13 +901,14 @@ class _HomeScreenState extends State { children: [ Icon(icon, size: 14, color: color), const SizedBox(width: 4), - Text(text, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: color)), + Text(text, + style: TextStyle( + fontSize: 12, fontWeight: FontWeight.w600, color: color)), ], ), ); } - /// Reconnecting overlay shown centered over the map during auto-reconnect Widget _buildReconnectingOverlay(AppStateProvider appState) { final deviceName = appState.rememberedDevice?.displayName ?? 'device'; @@ -714,6 +974,182 @@ class _HomeScreenState extends State { ); } + /// Zone grace period overlay shown centered over the map when outside zone + Widget _buildZoneGraceOverlay(AppStateProvider appState) { + final nearestName = appState.nearestZoneName; + final nearestDistance = appState.nearestZoneDistanceKm; + final hasNearestInfo = nearestName != null && nearestDistance != null; + + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 260), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + decoration: BoxDecoration( + color: const Color(0xFF1E293B), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.location_off, + color: Colors.orange.shade400, + size: 36, + ), + const SizedBox(height: 16), + Text( + 'Out of Zone', + style: TextStyle( + color: Colors.grey.shade100, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + if (hasNearestInfo) ...[ + const SizedBox(height: 8), + Text( + 'Nearest: $nearestName (${nearestDistance.toStringAsFixed(1)} km)', + style: TextStyle( + color: Colors.grey.shade400, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 16), + Text( + appState.zoneGraceCountdownFormatted, + style: TextStyle( + color: Colors.orange.shade400, + fontSize: 28, + fontWeight: FontWeight.bold, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + const SizedBox(height: 12), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.grey.shade500, + ), + ), + const SizedBox(width: 8), + Text( + 'Searching for zone...', + style: TextStyle( + color: Colors.grey.shade500, + fontSize: 13, + ), + ), + ], + ), + const SizedBox(height: 20), + OutlinedButton( + onPressed: () => appState.cancelZoneGracePeriod(), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.orange.shade400, + side: BorderSide(color: Colors.orange.shade400), + ), + child: const Text('Cancel'), + ), + ], + ), + ), + ); + } + + /// Zone transfer overlay shown centered over the map during zone-to-zone transfer + Widget _buildZoneTransferOverlay(AppStateProvider appState) { + final from = appState.zoneTransferFrom ?? '?'; + final to = appState.zoneTransferTo ?? '?'; + + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 260), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + decoration: BoxDecoration( + color: const Color(0xFF1E293B), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + color: Colors.orange.shade400, + ), + const SizedBox(height: 20), + Text( + 'Changing Zone...', + style: TextStyle( + color: Colors.grey.shade100, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + '$from → $to', + style: TextStyle( + color: Colors.grey.shade400, + fontSize: 14, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.grey.shade500, + ), + ), + const SizedBox(width: 8), + Text( + 'Re-authenticating...', + style: TextStyle( + color: Colors.grey.shade500, + fontSize: 13, + ), + ), + ], + ), + const SizedBox(height: 20), + OutlinedButton( + onPressed: () => appState.cancelZoneTransfer(), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.orange.shade400, + side: BorderSide(color: Colors.orange.shade400), + ), + child: const Text('Cancel'), + ), + ], + ), + ), + ); + } + Widget _buildControlPanel() { return Card( margin: const EdgeInsets.all(8), @@ -722,7 +1158,8 @@ class _HomeScreenState extends State { children: [ // Header with help and minimize buttons ListTile( - title: const Text('Controls', style: TextStyle(fontWeight: FontWeight.bold)), + title: const Text('Controls', + style: TextStyle(fontWeight: FontWeight.bold)), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -797,7 +1234,8 @@ class _HomeScreenState extends State { /// Show help bottom sheet explaining each control void _showControlsHelp(BuildContext context) { - final prefs = Provider.of(context, listen: false).preferences; + final prefs = + Provider.of(context, listen: false).preferences; showModalBottomSheet( context: context, useSafeArea: true, @@ -851,7 +1289,8 @@ class _HomeScreenState extends State { icon: Icons.settings_input_antenna, color: Colors.orange, title: 'External Antenna', - description: 'Enable if using an external antenna (ex: mag mount on roof of car). We store this along with pings as external antennas can make a big difference in reception.', + description: + 'Enable if using an external antenna (ex: mag mount on roof of car). We store this along with pings as external antennas can make a big difference in reception.', ), // Send Ping button @@ -859,12 +1298,15 @@ class _HomeScreenState extends State { icon: Icons.cell_tower, color: const Color(0xFF0EA5E9), title: 'Send Ping', - description: 'Send a single ping to #wardriving and track which repeaters heard it.', + description: + 'Send a single ping to #wardriving and track which repeaters heard it.', ), // Active Mode / Hybrid Mode button _buildHelpItem( - icon: prefs.hybridModeEnabled ? Icons.compare_arrows : Icons.sensors, + icon: prefs.hybridModeEnabled + ? Icons.compare_arrows + : Icons.sensors, color: const Color(0xFF6366F1), title: prefs.hybridModeEnabled ? 'Hybrid Mode' : 'Active Mode', description: prefs.hybridModeEnabled @@ -877,23 +1319,17 @@ class _HomeScreenState extends State { icon: Icons.hearing, color: const Color(0xFF6366F1), title: 'Passive Mode', - description: 'Sends zero-hop discovery pings every 30s, tracks nearby repeaters and received mesh traffic.', + description: + 'Sends zero-hop discovery pings every 30s, tracks nearby repeaters and received mesh traffic.', ), - // Offline mode toggle + // Trace Mode _buildHelpItem( - icon: Icons.cloud_off, - color: Colors.orange, - title: 'Offline Mode', - description: 'Save pings locally instead of uploading immediately. Useful when you have poor connectivity. Upload saved sessions later from the Settings tab.', - ), - - // Sound toggle - _buildHelpItem( - icon: Icons.volume_up, - color: Colors.blue, - title: 'Sound', - description: 'Sonar tone when sending TX/Discovery pings. Message tone when receiving valid RX packets, heard repeaters, or discovery responses.', + icon: Icons.gps_fixed, + color: Colors.cyan, + title: 'Trace Mode', + description: + 'Sends a zero-hop trace to a specific repeater by its hex ID at your set interval. Shows signal quality (SNR/RSSI) for that one repeater over time — useful for antenna alignment or testing a specific node.', ), const SizedBox(height: 8), @@ -995,9 +1431,7 @@ class _HomeScreenState extends State { /// Get color based on noise floor value (lower is better) Color _getNoiseFloorColor(int noiseFloor) { - if (noiseFloor <= -100) return Colors.green; // -100 to -120: great - if (noiseFloor <= -90) return Colors.orange; // -90 to -100: okay - return Colors.red; // 0 to -90: bad + return PingColors.noiseFloorColor(noiseFloor.toDouble()); } /// Get battery icon based on percentage @@ -1017,4 +1451,3 @@ class _HomeScreenState extends State { return Colors.red; } } - diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index 9519772f..84714d68 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -3,10 +3,13 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../models/log_entry.dart'; +import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; +import '../utils/ping_colors.dart'; import '../widgets/repeater_id_chip.dart'; +import '../widgets/rx_path_chain.dart'; -/// Log screen with tabs for TX Log, RX Log, and User Errors +/// Log screen with two tabs: All Pings (unified TX+RX+DISC+TRC) and Errors class LogScreen extends StatefulWidget { const LogScreen({super.key}); @@ -14,13 +17,15 @@ class LogScreen extends StatefulWidget { State createState() => _LogScreenState(); } -class _LogScreenState extends State with SingleTickerProviderStateMixin { +class _LogScreenState extends State + with SingleTickerProviderStateMixin { late TabController _tabController; + final _allPingsKey = GlobalKey<_AllPingsTabState>(); @override void initState() { super.initState(); - _tabController = TabController(length: 4, vsync: this); + _tabController = TabController(length: 2, vsync: this); } @override @@ -36,234 +41,181 @@ class _LogScreenState extends State with SingleTickerProviderStateMix // Auto-switch to Error tab when requested if (appState.requestErrorLogSwitch) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _tabController.index != 3) { - _tabController.animateTo(3); // Switch to Error tab + if (mounted && _tabController.index != 1) { + _tabController.animateTo(1); // Switch to Error tab setState(() {}); } appState.clearErrorLogSwitchRequest(); }); } + final totalPings = appState.txLogEntries.length + + appState.rxLogEntries.length + + appState.discLogEntries.length + + appState.traceLogEntries.length; + + final errorCount = appState.errorLogEntries.length; + return Scaffold( appBar: AppBar( - title: const Text('Logs'), + toolbarHeight: 40, + title: const Text('Logs', style: TextStyle(fontSize: 18)), actions: [ - IconButton( - icon: const Icon(Icons.copy), - onPressed: () => _copyCurrentTabToCsv(context, appState), - tooltip: 'Copy CSV', - ), - IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: () => _confirmClearLogs(context, appState), - tooltip: 'Clear all logs', + PopupMenuButton( + icon: const Icon(Icons.more_vert, size: 20), + padding: EdgeInsets.zero, + onSelected: (value) { + if (value == 'copy') _copyCurrentTabToCsv(context, appState); + if (value == 'clear') _confirmClearLogs(context, appState); + }, + itemBuilder: (context) => [ + const PopupMenuItem(value: 'copy', child: Text('Copy CSV')), + const PopupMenuItem( + value: 'clear', child: Text('Clear all logs')), + ], ), ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(32), + child: TabBar( + controller: _tabController, + indicatorSize: TabBarIndicatorSize.tab, + dividerHeight: 1, + labelPadding: EdgeInsets.zero, + tabs: [ + Tab( + height: 32, + text: 'All Pings${totalPings > 0 ? ' ($totalPings)' : ''}'), + Tab( + height: 32, + text: 'Errors${errorCount > 0 ? ' ($errorCount)' : ''}'), + ], + ), + ), ), - body: Column( + body: TabBarView( + controller: _tabController, children: [ - // Secondary bar with tabs (full width) - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.15), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - children: [ - Expanded(child: _buildTabChip(0, 'TX', appState.txLogEntries.length, isTx: true)), - const SizedBox(width: 8), - Expanded(child: _buildTabChip(1, 'RX', appState.rxLogEntries.length, isRx: true)), - const SizedBox(width: 8), - Expanded(child: _buildTabChip(2, 'DISC', appState.discLogEntries.length, isDisc: true)), - const SizedBox(width: 8), - Expanded(child: _buildTabChip(3, 'Errors', appState.errorLogEntries.length, isError: true)), - ], - ), - ), - // Tab content - Expanded( - child: TabBarView( - controller: _tabController, - children: [ - _TxLogTab(entries: appState.txLogEntries), - _RxLogTab(entries: appState.rxLogEntries), - _DiscLogTab(entries: appState.discLogEntries), - _ErrorLogTab(entries: appState.errorLogEntries), - ], - ), + _AllPingsTab( + key: _allPingsKey, + allEntries: appState.unifiedPingLogEntries, + repeaters: appState.repeaters, + txCount: appState.txLogEntries.length, + rxCount: appState.rxLogEntries.length, + discCount: appState.discLogEntries.length, + traceCount: appState.traceLogEntries.length, ), + _ErrorLogTab(entries: appState.errorLogEntries), ], ), ); } - /// Build a tab chip that matches StatusBar chip styling - Widget _buildTabChip(int index, String label, int count, {bool isError = false, bool isDisc = false, bool isTx = false, bool isRx = false}) { - final theme = Theme.of(context); - // Colors matching status bar chips - const discColor = Color(0xFF7B68EE); // DISC purple - const txColor = Colors.green; // TX green (matches status bar) - const rxColor = Colors.blue; // RX blue (matches status bar) - - return GestureDetector( - onTap: () { - _tabController.animateTo(index); - setState(() {}); - }, - child: AnimatedBuilder( - animation: _tabController, - builder: (context, child) { - final isCurrentlySelected = _tabController.index == index; - Color currentColor; - if (isCurrentlySelected) { - if (isError) { - currentColor = Colors.red; - } else if (isDisc) { - currentColor = discColor; - } else if (isTx) { - currentColor = txColor; - } else if (isRx) { - currentColor = rxColor; - } else { - currentColor = theme.colorScheme.primary; - } - } else { - currentColor = theme.colorScheme.onSurfaceVariant; - } - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: currentColor.withValues(alpha: isCurrentlySelected ? 0.15 : 0.08), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: currentColor.withValues(alpha: isCurrentlySelected ? 0.4 : 0.2), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: isCurrentlySelected ? FontWeight.w600 : FontWeight.w500, - color: currentColor, - ), - ), - if (count > 0) ...[ - const SizedBox(width: 4), - Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), - decoration: BoxDecoration( - color: currentColor, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - count > 99 ? '99+' : count.toString(), - style: const TextStyle( - fontSize: 9, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ), - ], - ], - ), - ); - }, - ), - ); - } - void _copyCurrentTabToCsv(BuildContext context, AppStateProvider appState) { - final currentTab = _tabController.index; - - switch (currentTab) { - case 0: // TX Log - _copyTxLogToCsv(context, appState.txLogEntries); - break; - case 1: // RX Log - _copyRxLogToCsv(context, appState.rxLogEntries); - break; - case 2: // DISC Log - _copyDiscLogToCsv(context, appState.discLogEntries); - break; - case 3: // Error Log - _copyErrorLogToCsv(context, appState.errorLogEntries); - break; + if (_tabController.index == 0) { + _copyAllPingsToCsv(context, appState); + } else { + _copyErrorLogToCsv(context, appState.errorLogEntries); } } - void _copyTxLogToCsv(BuildContext context, List entries) { - if (entries.isEmpty) { + void _copyAllPingsToCsv(BuildContext context, AppStateProvider appState) { + // If a search filter is active, export only the filtered unified entries + final tabState = _allPingsKey.currentState; + final searchQuery = tabState?._searchQuery ?? ''; + if (searchQuery.isNotEmpty && tabState != null) { + final filtered = tabState._filteredEntries; + if (filtered.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('No matching entries to copy'), + duration: Duration(seconds: 2)), + ); + return; + } + final buffer = StringBuffer(); + buffer.writeln('type,data'); + for (final entry in filtered) { + buffer.writeln(entry.toCsv()); + } + Clipboard.setData(ClipboardData(text: buffer.toString())); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No TX log entries to copy'), duration: Duration(seconds: 2)), + SnackBar( + content: + Text('${filtered.length} filtered entries copied to clipboard'), + duration: const Duration(seconds: 2)), ); return; } - final buffer = StringBuffer(); - buffer.writeln('timestamp,latitude,longitude,power,events'); - for (final entry in entries) { - buffer.writeln(entry.toCsv()); - } - Clipboard.setData(ClipboardData(text: buffer.toString())); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('TX log copied to clipboard'), duration: Duration(seconds: 2)), - ); - } + final tx = appState.txLogEntries; + final rx = appState.rxLogEntries; + final disc = appState.discLogEntries; + final trace = appState.traceLogEntries; - void _copyRxLogToCsv(BuildContext context, List entries) { - if (entries.isEmpty) { + if (tx.isEmpty && rx.isEmpty && disc.isEmpty && trace.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No RX log entries to copy'), duration: Duration(seconds: 2)), + const SnackBar( + content: Text('No ping log entries to copy'), + duration: Duration(seconds: 2)), ); return; } final buffer = StringBuffer(); - buffer.writeln('timestamp,repeater_id,snr,rssi,path_length,header,latitude,longitude'); - for (final entry in entries) { - buffer.writeln(entry.toCsv()); + + if (tx.isNotEmpty) { + buffer.writeln('--- TX Log ---'); + buffer.writeln('timestamp,latitude,longitude,power,events'); + for (final entry in tx) { + buffer.writeln(entry.toCsv()); + } + buffer.writeln(); } - Clipboard.setData(ClipboardData(text: buffer.toString())); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('RX log copied to clipboard'), duration: Duration(seconds: 2)), - ); - } - void _copyDiscLogToCsv(BuildContext context, List entries) { - if (entries.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No DISC log entries to copy'), duration: Duration(seconds: 2)), - ); - return; + if (rx.isNotEmpty) { + buffer.writeln('--- RX Log ---'); + buffer.writeln( + 'timestamp,repeater_id,snr,rssi,path_length,header,latitude,longitude,path_hops'); + for (final entry in rx) { + buffer.writeln(entry.toCsv()); + } + buffer.writeln(); } - final buffer = StringBuffer(); - buffer.writeln('timestamp,latitude,longitude,noisefloor,node_count,nodes'); - for (final entry in entries) { - buffer.writeln(entry.toCsv()); + if (disc.isNotEmpty) { + buffer.writeln('--- DISC Log ---'); + buffer + .writeln('timestamp,latitude,longitude,noisefloor,node_count,nodes'); + for (final entry in disc) { + buffer.writeln(entry.toCsv()); + } + buffer.writeln(); + } + + if (trace.isNotEmpty) { + buffer.writeln('--- TRC Log ---'); + buffer.writeln( + 'timestamp,target_repeater,local_snr,local_rssi,remote_snr,latitude,longitude,noisefloor,success'); + for (final entry in trace) { + buffer.writeln(entry.toCsv()); + } } + Clipboard.setData(ClipboardData(text: buffer.toString())); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('DISC log copied to clipboard'), duration: Duration(seconds: 2)), + const SnackBar( + content: Text('All ping logs copied to clipboard'), + duration: Duration(seconds: 2)), ); } void _copyErrorLogToCsv(BuildContext context, List entries) { if (entries.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No error log entries to copy'), duration: Duration(seconds: 2)), + const SnackBar( + content: Text('No error log entries to copy'), + duration: Duration(seconds: 2)), ); return; } @@ -275,7 +227,9 @@ class _LogScreenState extends State with SingleTickerProviderStateMix } Clipboard.setData(ClipboardData(text: buffer.toString())); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Error log copied to clipboard'), duration: Duration(seconds: 2)), + const SnackBar( + content: Text('Error log copied to clipboard'), + duration: Duration(seconds: 2)), ); } @@ -284,7 +238,8 @@ class _LogScreenState extends State with SingleTickerProviderStateMix context: context, builder: (context) => AlertDialog( title: const Text('Clear All Logs?'), - content: const Text('This will clear TX, RX, DISC, and error logs.'), + content: + const Text('This will clear TX, RX, DISC, TRC, and error logs.'), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -303,235 +258,405 @@ class _LogScreenState extends State with SingleTickerProviderStateMix } } -/// TX Log Tab -class _TxLogTab extends StatelessWidget { - final List entries; +// ============================================================================= +// All Pings Tab — unified chronological view with type filters +// ============================================================================= + +class _AllPingsTab extends StatefulWidget { + final List allEntries; + final List repeaters; + final int txCount; + final int rxCount; + final int discCount; + final int traceCount; + + const _AllPingsTab({ + super.key, + required this.allEntries, + required this.repeaters, + required this.txCount, + required this.rxCount, + required this.discCount, + required this.traceCount, + }); + + @override + State<_AllPingsTab> createState() => _AllPingsTabState(); +} + +class _AllPingsTabState extends State<_AllPingsTab> { + final Set _activeFilters = { + PingLogType.tx, + PingLogType.rx, + PingLogType.disc, + PingLogType.trace, + }; - const _TxLogTab({required this.entries}); + String _searchQuery = ''; + final TextEditingController _searchController = TextEditingController(); + + /// Current filtered entries (used by CSV export) + List _filteredEntries = []; @override - Widget build(BuildContext context) { - if (entries.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.upload_outlined, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(height: 16), - Text('No TX pings logged yet', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), - ], - ), - ); - } + void dispose() { + _searchController.dispose(); + super.dispose(); + } - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: entries.length, - itemBuilder: (context, index) { - // Most recent first (no reverse needed - entries already in chronological order) - final entry = entries[entries.length - 1 - index]; - return _buildTxEntry(context, entry); - }, - ); + void _toggleFilter(PingLogType type) { + setState(() { + if (_activeFilters.contains(type)) { + // Don't allow deselecting the last filter + if (_activeFilters.length > 1) { + _activeFilters.remove(type); + } + } else { + _activeFilters.add(type); + } + }); } - Widget _buildTxEntry(BuildContext context, TxLogEntry entry) { - final appState = context.read(); + // --------------------------------------------------------------------------- + // Repeater name resolution & search matching + // --------------------------------------------------------------------------- + + /// Resolve a short repeater ID to known repeater names via prefix matching. + static ({List names, bool ambiguous}) _resolveRepeaterNames( + String repeaterId, + List repeaters, + ) { + final idLower = repeaterId.toLowerCase(); + final matches = repeaters + .where((r) => r.hexId.toLowerCase().startsWith(idLower)) + .map((r) => r.name) + .toList(); + return (names: matches, ambiguous: matches.length > 1); + } - return Card( - margin: const EdgeInsets.only(bottom: 8), - color: Theme.of(context).colorScheme.surfaceContainerHigh, - child: InkWell( - onTap: () { - // Navigate to map and show this location - // Main scaffold will handle switching to map tab - appState.navigateToMapCoordinates(entry.latitude, entry.longitude); - }, - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header row: Time and Power - Row( - children: [ - // Time badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - entry.timeString, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, - ), - ), + /// Whether a hex ID has ambiguous name matches (maps to multiple repeaters). + static bool _isAmbiguousId(String repeaterId, List repeaters) { + return _resolveRepeaterNames(repeaterId, repeaters).ambiguous; + } + + /// True if the query looks like a hex string (only 0-9, a-f). + /// Whether an entry matches the current search query. + bool _matchesSearch(UnifiedPingLogEntry entry, List repeaters) { + if (_searchQuery.isEmpty) return true; + final query = _searchQuery.toLowerCase(); + + switch (entry.type) { + case PingLogType.tx: + final tx = entry.asTx; + for (final event in tx.events) { + if (event.repeaterId.toLowerCase().startsWith(query)) return true; + final resolved = _resolveRepeaterNames(event.repeaterId, repeaters); + if (resolved.names.any((n) => n.toLowerCase().contains(query))) { + return true; + } + } + for (final event in tx.multiHopEvents) { + if (event.repeaterId.toLowerCase().startsWith(query)) return true; + final resolved = _resolveRepeaterNames(event.repeaterId, repeaters); + if (resolved.names.any((n) => n.toLowerCase().contains(query))) { + return true; + } + } + return false; + case PingLogType.rx: + final rx = entry.asRx; + if (rx.repeaterId.toLowerCase().startsWith(query)) return true; + final resolved = _resolveRepeaterNames(rx.repeaterId, repeaters); + return resolved.names.any((n) => n.toLowerCase().contains(query)); + case PingLogType.disc: + final disc = entry.asDisc; + for (final node in disc.discoveredNodes) { + if (node.repeaterId.toLowerCase().startsWith(query)) return true; + if (node.pubkeyHex != null && + node.pubkeyHex!.toLowerCase().startsWith(query)) { + return true; + } + final resolved = _resolveRepeaterNames(node.repeaterId, repeaters); + if (resolved.names.any((n) => n.toLowerCase().contains(query))) { + return true; + } + } + return false; + case PingLogType.trace: + final trace = entry.asTrace; + if (trace.targetRepeaterId.toLowerCase().startsWith(query)) return true; + final resolved = + _resolveRepeaterNames(trace.targetRepeaterId, repeaters); + return resolved.names.any((n) => n.toLowerCase().contains(query)); + } + } + + /// Whether an entry should show the ambiguity indicator. + /// Only shown when searching by name (non-hex query) and a repeater ID is ambiguous. + bool _shouldShowAmbiguity( + UnifiedPingLogEntry entry, List repeaters) { + switch (entry.type) { + case PingLogType.tx: + return entry.asTx.events + .any((e) => _isAmbiguousId(e.repeaterId, repeaters)) || + entry.asTx.multiHopEvents + .any((e) => _isAmbiguousId(e.repeaterId, repeaters)); + case PingLogType.rx: + return _isAmbiguousId(entry.asRx.repeaterId, repeaters); + case PingLogType.disc: + return entry.asDisc.discoveredNodes + .any((n) => _isAmbiguousId(n.repeaterId, repeaters)); + case PingLogType.trace: + return _isAmbiguousId(entry.asTrace.targetRepeaterId, repeaters); + } + } + + @override + Widget build(BuildContext context) { + final filtered = widget.allEntries + .where((e) => _activeFilters.contains(e.type)) + .where((e) => _matchesSearch(e, widget.repeaters)) + .toList(); + _filteredEntries = filtered; + + final hasEntries = widget.allEntries.isNotEmpty; + final hasResults = filtered.isNotEmpty; + + return Column( + children: [ + // Search bar + Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 0), + child: SizedBox( + height: 36, + child: TextField( + controller: _searchController, + style: const TextStyle(fontSize: 13), + decoration: InputDecoration( + hintText: 'Search by repeater name or ID', + hintStyle: const TextStyle(fontSize: 13), + prefixIcon: const Icon(Icons.search, size: 18), + prefixIconConstraints: const BoxConstraints(minWidth: 36), + suffixIcon: _searchQuery.isNotEmpty + ? GestureDetector( + onTap: () { + _searchController.clear(); + setState(() => _searchQuery = ''); + }, + child: const Icon(Icons.close, size: 18), + ) + : null, + suffixIconConstraints: const BoxConstraints(minWidth: 36), + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 8), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), ), - const Spacer(), - // Power indicator (watts) - Text( - '${entry.power.toStringAsFixed(1)} W', - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontFamily: 'monospace', - ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), ), - ], + ), + onChanged: (value) => setState(() => _searchQuery = value.trim()), ), - const SizedBox(height: 8), - - // Location - Row( - children: [ - Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 4), - Text( - entry.locationString, - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontFamily: 'monospace', - ), - ), - ], + ), + ), + // Filter segmented row + Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 0), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), ), - - // Repeaters table - if (entry.events.isNotEmpty) ...[ - const SizedBox(height: 10), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), - ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: Row( - children: [ - SizedBox( - width: 50, - child: Text( - 'Node', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ], + clipBehavior: Clip.antiAlias, + child: IntrinsicHeight( + child: Row( + children: [ + _buildFilterSegment(PingLogType.tx, 'TX', widget.txCount, + PingColors.txSuccess, + isFirst: true), + _segmentDivider(context), + _buildFilterSegment( + PingLogType.rx, 'RX', widget.rxCount, PingColors.rx), + _segmentDivider(context), + _buildFilterSegment(PingLogType.disc, 'DISC', + widget.discCount, PingColors.discSuccess), + _segmentDivider(context), + _buildFilterSegment(PingLogType.trace, 'TRC', + widget.traceCount, PingColors.traceSuccess, + isLast: true), + ], + ), + ), + ), + ), + // List + Expanded( + child: !hasResults + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + hasEntries ? Icons.search_off : Icons.list_alt, + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), - ), - Divider(height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...entry.events.map((event) => _buildRepeaterRow(context, event)), - ], + const SizedBox(height: 16), + Text( + hasEntries && _searchQuery.isNotEmpty + ? 'No results for \'$_searchQuery\'' + : 'No pings logged yet', + style: TextStyle( + color: + Theme.of(context).colorScheme.onSurfaceVariant), + ), + ], + ), + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + itemCount: filtered.length, + itemBuilder: (context, index) { + final unified = filtered[index]; + final showAmbiguity = + _shouldShowAmbiguity(unified, widget.repeaters); + return switch (unified.type) { + PingLogType.tx => _buildTxCard(context, unified.asTx, + showAmbiguity: showAmbiguity), + PingLogType.rx => _buildRxCard(context, unified.asRx, + showAmbiguity: showAmbiguity), + PingLogType.disc => _buildDiscCard( + context, unified.asDisc, + showAmbiguity: showAmbiguity), + PingLogType.trace => _buildTraceCard( + context, unified.asTrace, + showAmbiguity: showAmbiguity), + }; + }, ), - ), - ] else ...[ - const SizedBox(height: 8), + ), + ], + ); + } + + Widget _buildFilterSegment( + PingLogType type, String label, int count, Color color, + {bool isFirst = false, bool isLast = false}) { + final active = _activeFilters.contains(type); + return Expanded( + child: GestureDetector( + onTap: () => _toggleFilter(type), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 7), + color: active ? color.withValues(alpha: 0.12) : Colors.transparent, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ Text( - 'No repeaters heard', + label, style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, + fontSize: 13, + fontWeight: active ? FontWeight.w600 : FontWeight.w500, + color: active + ? color + : Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.6), ), ), + if (count > 0) ...[ + const SizedBox(width: 4), + Container( + constraints: + const BoxConstraints(minWidth: 18, minHeight: 16), + padding: + const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: active + ? color + : Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: Text( + count > 99 ? '99+' : count.toString(), + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.white, + height: 1.0, + ), + ), + ), + ], ], - ], + ), ), ), - ), ); } - /// Build a table row for a repeater event - Widget _buildRepeaterRow(BuildContext context, RxEvent event) { - Color snrColor; - switch (event.severity) { - case SnrSeverity.poor: - snrColor = Colors.red; - case SnrSeverity.fair: - snrColor = Colors.orange; - case SnrSeverity.good: - snrColor = Colors.green; - case null: - snrColor = Colors.grey; - } - - // RSSI color based on signal strength - Color rssiColor; - if (event.rssi == null) { - rssiColor = Colors.grey; - } else if (event.rssi! >= -70) { - rssiColor = Colors.green; - } else if (event.rssi! >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } + static Widget _segmentDivider(BuildContext context) { + return VerticalDivider( + width: 1, + thickness: 1, + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + ); + } - return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, event.repeaterId), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - child: Row( - children: [ - // Repeater ID - RepeaterIdChip(repeaterId: event.repeaterId, fontSize: 11, width: 50), - // SNR - Expanded( - child: Center( - child: _buildTxChip(event.snr?.toStringAsFixed(1) ?? '-', snrColor), - ), - ), - // RSSI - Expanded( - child: Center( - child: _buildTxChip(event.rssi != null ? '${event.rssi}' : '-', rssiColor), - ), - ), - ], + // --------------------------------------------------------------------------- + // Type badge + // --------------------------------------------------------------------------- + + static Widget _buildTypeBadge(PingLogType type) { + final (label, color) = switch (type) { + PingLogType.tx => ('TX', PingColors.txSuccess), + PingLogType.rx => ('RX', PingColors.rx), + PingLogType.disc => ('DISC', PingColors.discSuccess), + PingLogType.trace => ('TRC', PingColors.traceSuccess), + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: color, ), ), ); } - /// Build a small colored chip for TX table cells - Widget _buildTxChip(String value, Color color) { + // --------------------------------------------------------------------------- + // Shared chip builder + // --------------------------------------------------------------------------- + + static Widget _buildChip(String value, Color color) { return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( @@ -542,7 +667,7 @@ class _TxLogTab extends StatelessWidget { child: Text( value, style: TextStyle( - fontSize: 10, + fontSize: 11, fontWeight: FontWeight.w600, color: color, fontFamily: 'monospace', @@ -550,396 +675,451 @@ class _TxLogTab extends StatelessWidget { ), ); } -} - -/// RX Log Tab -class _RxLogTab extends StatelessWidget { - final List entries; - const _RxLogTab({required this.entries}); + // --------------------------------------------------------------------------- + // TX Card + // --------------------------------------------------------------------------- + + /// Width of the "Node" column sized from the hex-ID length actually being + /// rendered. RX paths can carry longer IDs than the global TX hop-byte + /// setting, so we measure per-card instead of per-session. + double _nodeColumnWidthForLength(int idLength) { + if (idLength <= 2) return 60; + if (idLength <= 4) return 70; + // 6-char (3-byte) IDs need extra room or the last digit clips on some + // devices (#383). + return 88; + } - @override - Widget build(BuildContext context) { - if (entries.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.download_outlined, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(height: 16), - Text('No RX observations yet', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), - ], - ), - ); - } - - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: entries.length, - itemBuilder: (context, index) { - // Most recent first (no reverse needed - entries already in chronological order) - final entry = entries[entries.length - 1 - index]; - return _buildRxEntry(context, entry); - }, - ); - } - - Widget _buildRxEntry(BuildContext context, RxLogEntry entry) { + Widget _buildTxCard(BuildContext context, TxLogEntry entry, + {bool showAmbiguity = false}) { final appState = context.read(); - - Color snrColor; - switch (entry.severity) { - case SnrSeverity.poor: - snrColor = Colors.red; - case SnrSeverity.fair: - snrColor = Colors.orange; - case SnrSeverity.good: - snrColor = Colors.green; - case null: - snrColor = Colors.grey; - } - - // RSSI color based on signal strength - Color rssiColor; - if (entry.rssi == null) { - rssiColor = Colors.grey; - } else if (entry.rssi! >= -70) { - rssiColor = Colors.green; // Strong: -30 to -70 dBm - } else if (entry.rssi! >= -100) { - rssiColor = Colors.orange; // Medium: -70 to -100 dBm - } else { - rssiColor = Colors.red; // Weak: -100 to -120 dBm - } - return Card( margin: const EdgeInsets.only(bottom: 8), color: Theme.of(context).colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () { - // Navigate to map and show this location - // Main scaffold will handle switching to map tab - appState.navigateToMapCoordinates(entry.latitude, entry.longitude); - }, + onTap: () => + appState.navigateToMapCoordinates(entry.latitude, entry.longitude), child: Padding( padding: const EdgeInsets.all(12), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header row: Time badge only - Row( - children: [ - // Time badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(6), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCardHeader(context, PingLogType.tx, entry.timeString, + entry.locationString, + showAmbiguity: showAmbiguity), + // Direct echoes table + if (entry.events.isNotEmpty) ...[ + const SizedBox(height: 10), + _buildRepeaterTable(context, entry.events, widget.repeaters), + ] else if (entry.multiHopEvents.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + 'No direct repeats heard', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, ), - child: Text( - entry.timeString, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, - ), + ), + ], + // Multi-hop echoes section + if (entry.multiHopEvents.isNotEmpty) ...[ + const SizedBox(height: 10), + _buildMultiHopSection( + context, entry.multiHopEvents, widget.repeaters), + ], + // No echoes at all + if (entry.events.isEmpty && entry.multiHopEvents.isEmpty) ...[ + const SizedBox(height: 8), + Text( + 'No repeaters heard', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, ), ), ], + ], + ), + ), + ), + ); + } + + Widget _buildRepeaterTable( + BuildContext context, List events, List repeaters) { + var maxIdLen = 0; + for (final e in events) { + if (e.repeaterId.length > maxIdLen) maxIdLen = e.repeaterId.length; + } + final nodeWidth = _nodeColumnWidthForLength(maxIdLen); + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + SizedBox( + width: nodeWidth, child: _tableHeader(context, 'Node')), + Expanded(child: _tableHeader(context, 'SNR', center: true)), + Expanded(child: _tableHeader(context, 'RSSI', center: true)), + ], ), - const SizedBox(height: 8), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + ...events.map((event) => + _buildTxRepeaterRow(context, event, nodeWidth, repeaters)), + ], + ), + ); + } - // Location - Row( + Widget _buildTxRepeaterRow(BuildContext context, RxEvent event, + double nodeWidth, List repeaters) { + final snrColor = _snrColor(event.severity); + final rssiColor = _rssiColor(event.rssi); + final isAmbiguous = _isAmbiguousId(event.repeaterId, repeaters); + return InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup(context, event.repeaterId), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + RepeaterIdChip( + repeaterId: event.repeaterId, + fontSize: 14, + width: nodeWidth, + isAmbiguous: isAmbiguous), + Expanded( + child: Center( + child: _buildChip( + event.snr?.toStringAsFixed(1) ?? '-', snrColor))), + Expanded( + child: Center( + child: _buildChip( + event.rssi != null ? '${event.rssi}' : '-', + rssiColor))), + ], + ), + ), + ); + } + + // --------------------------------------------------------------------------- + // Multi-hop Echo Section (inside TX Card) + // --------------------------------------------------------------------------- + + Widget _buildMultiHopSection(BuildContext context, + List events, List repeaters) { + var maxIdLen = 0; + for (final e in events) { + if (e.repeaterId.length > maxIdLen) maxIdLen = e.repeaterId.length; + } + final nodeWidth = _nodeColumnWidthForLength(maxIdLen); + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( children: [ - Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 4), + Icon(Icons.route, size: 14, color: PingColors.rx), + const SizedBox(width: 6), Text( - entry.locationString, + 'Multi-hop Repeats', style: TextStyle( fontSize: 11, + fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.onSurfaceVariant, - fontFamily: 'monospace', ), ), ], ), - const SizedBox(height: 10), - - // Repeater table (single row) - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), - ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + // Table header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + SizedBox( + width: nodeWidth, child: _tableHeader(context, 'Node')), + Expanded(child: _tableHeader(context, 'SNR', center: true)), + Expanded(child: _tableHeader(context, 'RSSI', center: true)), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + ...events.map((event) { + final snrColor = _snrColor(event.severity); + final rssiColor = _rssiColor(event.rssi); + final isAmbiguous = _isAmbiguousId(event.repeaterId, repeaters); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, event.repeaterId), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ - SizedBox( - width: 50, - child: Text( - 'Node', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), + RepeaterIdChip( + repeaterId: event.repeaterId, + fontSize: 14, + width: nodeWidth, + isAmbiguous: isAmbiguous), + Expanded( + child: Center( + child: _buildChip( + event.snr?.toStringAsFixed(1) ?? '-', + snrColor))), Expanded( - child: Text( - 'SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + child: Center( + child: _buildChip( + event.rssi != null ? '${event.rssi}' : '-', + rssiColor))), + ], + ), + ), + ), + if (event.pathHops.isNotEmpty) + Padding( + padding: + const EdgeInsets.only(left: 10, right: 10, bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + Icons.route, + size: 12, + color: + Theme.of(context).colorScheme.onSurfaceVariant, ), ), + const SizedBox(width: 4), Expanded( - child: Text( - 'RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + child: RxPathChain( + hops: event.pathHops, + fontSize: 11, ), ), ], ), ), - Divider(height: 1, color: Theme.of(context).dividerColor), - // Data row - InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.repeaterId), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + ], + ); + }), + ], + ), + ); + } + + // --------------------------------------------------------------------------- + // RX Card + // --------------------------------------------------------------------------- + + Widget _buildRxCard(BuildContext context, RxLogEntry entry, + {bool showAmbiguity = false}) { + final appState = context.read(); + final snrColor = _snrColor(entry.severity); + final rssiColor = _rssiColor(entry.rssi); + final nodeWidth = _nodeColumnWidthForLength(entry.repeaterId.length); + final isAmbiguous = _isAmbiguousId(entry.repeaterId, widget.repeaters); + + return Card( + margin: const EdgeInsets.only(bottom: 8), + color: Theme.of(context).colorScheme.surfaceContainerHigh, + child: InkWell( + onTap: () => + appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCardHeader(context, PingLogType.rx, entry.timeString, + entry.locationString, + showAmbiguity: showAmbiguity), + const SizedBox(height: 10), + // Repeater table (single row) + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 8), child: Row( children: [ - // Repeater ID - RepeaterIdChip(repeaterId: entry.repeaterId, fontSize: 11, width: 50), - // SNR + SizedBox( + width: nodeWidth, + child: _tableHeader(context, 'Node')), Expanded( - child: Center( - child: _buildRxChip(entry.snr?.toStringAsFixed(1) ?? '-', snrColor), - ), - ), - // RSSI + child: + _tableHeader(context, 'SNR', center: true)), Expanded( - child: Center( - child: _buildRxChip(entry.rssi != null ? '${entry.rssi}' : '-', rssiColor), - ), - ), + child: + _tableHeader(context, 'RSSI', center: true)), ], ), ), - ), - ], + Divider(height: 1, color: Theme.of(context).dividerColor), + InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, entry.repeaterId), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + child: Row( + children: [ + RepeaterIdChip( + repeaterId: entry.repeaterId, + fontSize: 14, + width: nodeWidth, + isAmbiguous: isAmbiguous), + Expanded( + child: Center( + child: _buildChip( + entry.snr?.toStringAsFixed(1) ?? '-', + snrColor))), + Expanded( + child: Center( + child: _buildChip( + entry.rssi != null + ? '${entry.rssi}' + : '-', + rssiColor))), + ], + ), + ), + ), + ], + ), ), - ), - ], - ), - ), - ), - ); - } - - /// Build a small colored chip for RX table cells - Widget _buildRxChip(String value, Color color) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: color.withValues(alpha: 0.4)), - ), - child: Text( - value, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: color, - fontFamily: 'monospace', + if (entry.pathHops.isNotEmpty) ...[ + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + Icons.route, + size: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 6), + Expanded( + child: RxPathChain( + hops: entry.pathHops, + fromLatLng: (lat: entry.latitude, lon: entry.longitude), + fontSize: 12, + ), + ), + ], + ), + ], + ], + ), ), ), ); } -} - -/// DISC Log Tab (Discovery observations from Passive Mode) -class _DiscLogTab extends StatelessWidget { - final List entries; - const _DiscLogTab({required this.entries}); + // --------------------------------------------------------------------------- + // DISC Card + // --------------------------------------------------------------------------- - @override - Widget build(BuildContext context) { - if (entries.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.radar_outlined, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(height: 16), - Text('No discovery observations yet', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), - const SizedBox(height: 8), - Text('Enable Passive Mode to discover nearby nodes', - style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12)), - ], - ), - ); - } - - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: entries.length, - itemBuilder: (context, index) { - final entry = entries[index]; // Already sorted most recent first - return _buildDiscEntry(context, entry); - }, - ); - } - - Widget _buildDiscEntry(BuildContext context, DiscLogEntry entry) { + Widget _buildDiscCard(BuildContext context, DiscLogEntry entry, + {bool showAmbiguity = false}) { final appState = context.read(); return Card( margin: const EdgeInsets.only(bottom: 8), color: Theme.of(context).colorScheme.surfaceContainerHigh, child: InkWell( - onTap: () { - // Navigate to map and show this location - appState.navigateToMapCoordinates(entry.latitude, entry.longitude); - }, + onTap: () => + appState.navigateToMapCoordinates(entry.latitude, entry.longitude), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header row: Time and node count (matching TX log style) - Row( - children: [ - // Time badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - entry.timeString, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - const Spacer(), - // Node count indicator (like power indicator in TX log) - Text( - '${entry.nodeCount} node${entry.nodeCount == 1 ? '' : 's'}', - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontFamily: 'monospace', - ), - ), - ], - ), - const SizedBox(height: 8), - - // Location - Row( - children: [ - Icon(Icons.location_on, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 4), - Text( - entry.locationString, - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontFamily: 'monospace', - ), - ), - ], - ), - + _buildCardHeader(context, PingLogType.disc, entry.timeString, + entry.locationString, + showAmbiguity: showAmbiguity), // Nodes table if (entry.discoveredNodes.isNotEmpty) ...[ const SizedBox(height: 10), Container( decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, + color: + Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Column( children: [ - // Header row Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 8), child: Row( children: [ SizedBox( - width: 50, - child: Text( - 'Node', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), + width: 70, + child: _tableHeader(context, 'Node')), Expanded( - child: Text( - 'RX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), + child: _tableHeader(context, 'RX SNR', + center: true)), Expanded( - child: Text( - 'RX RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), + child: _tableHeader(context, 'RX RSSI', + center: true)), Expanded( - child: Text( - 'TX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), + child: _tableHeader(context, 'TX SNR', + center: true)), ], ), ), Divider(height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...entry.discoveredNodes.map((node) => _buildNodeRow(context, node)), + ...entry.discoveredNodes.map((node) => + _buildDiscNodeRow(context, node, widget.repeaters)), ], ), ), @@ -948,7 +1128,7 @@ class _DiscLogTab extends StatelessWidget { Text( 'No nodes discovered', style: TextStyle( - fontSize: 11, + fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant, fontStyle: FontStyle.italic, ), @@ -961,109 +1141,253 @@ class _DiscLogTab extends StatelessWidget { ); } - /// Build a table row for a discovered node - Widget _buildNodeRow(BuildContext context, DiscoveredNodeEntry node) { - // Color for RX SNR (what we received) - Color rxSnrColor; - switch (node.severity) { - case SnrSeverity.poor: - rxSnrColor = Colors.red; - case SnrSeverity.fair: - rxSnrColor = Colors.orange; - case SnrSeverity.good: - rxSnrColor = Colors.green; - } - - // TX SNR color (what they received from us) - Color txSnrColor; - if (node.remoteSnr <= -1) { - txSnrColor = Colors.red; - } else if (node.remoteSnr <= 5) { - txSnrColor = Colors.orange; - } else { - txSnrColor = Colors.green; - } - - // RSSI color based on signal strength - Color rssiColor; - if (node.localRssi >= -70) { - rssiColor = Colors.green; - } else if (node.localRssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } + Widget _buildDiscNodeRow(BuildContext context, DiscoveredNodeEntry node, + List repeaters) { + final rxSnrColor = _snrColorFromValue(node.localSnr); + final rssiColor = _rssiColor(node.localRssi); + final txSnrColor = PingColors.snrColor(node.remoteSnr.toDouble()); + final isAmbiguous = _isAmbiguousId(node.repeaterId, repeaters); return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), + onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, + fullHexId: node.pubkeyHex), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ - // Node ID with type SizedBox( - width: 50, + width: 70, child: Row( children: [ - RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 11), + Flexible( + child: RepeaterIdChip( + repeaterId: node.repeaterId, + fontSize: 14, + isAmbiguous: isAmbiguous)), Text( node.nodeTypeLabel, - style: const TextStyle( - fontSize: 9, + style: TextStyle( + fontSize: 11, fontWeight: FontWeight.w500, - color: Color(0xFF7B68EE), + color: PingColors.discSuccess, ), ), ], ), ), - // RX SNR Expanded( - child: Center( - child: _buildChip(node.localSnr.toStringAsFixed(1), rxSnrColor), - ), - ), - // RSSI + child: Center( + child: _buildChip( + node.localSnr.toStringAsFixed(1), rxSnrColor))), Expanded( - child: Center( - child: _buildChip('${node.localRssi}', rssiColor), - ), - ), - // TX SNR + child: + Center(child: _buildChip('${node.localRssi}', rssiColor))), Expanded( - child: Center( - child: _buildChip(node.remoteSnr.toStringAsFixed(1), txSnrColor), - ), - ), + child: Center( + child: _buildChip( + node.remoteSnr.toStringAsFixed(1), txSnrColor))), ], ), ), ); } - /// Build a small colored chip for table cells - Widget _buildChip(String value, Color color) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: color.withValues(alpha: 0.4)), + // --------------------------------------------------------------------------- + // Trace Card + // --------------------------------------------------------------------------- + + Widget _buildTraceCard(BuildContext context, TraceLogEntry entry, + {bool showAmbiguity = false}) { + final colorScheme = Theme.of(context).colorScheme; + final appState = context.read(); + + return Card( + margin: const EdgeInsets.only(bottom: 8), + color: colorScheme.surfaceContainerHigh, + child: InkWell( + onTap: () => + appState.navigateToMapCoordinates(entry.latitude, entry.longitude), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCardHeader(context, PingLogType.trace, entry.timeString, + entry.locationString, + showAmbiguity: showAmbiguity), + // Results table + if (entry.success) ...[ + const SizedBox(height: 10), + Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 8), + child: Row( + children: [ + SizedBox( + width: 70, + child: _tableHeader(context, 'Node')), + Expanded( + child: _tableHeader(context, 'RX SNR', + center: true)), + Expanded( + child: _tableHeader(context, 'RX RSSI', + center: true)), + Expanded( + child: _tableHeader(context, 'TX SNR', + center: true)), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + _buildTraceNodeRow(context, entry, widget.repeaters), + ], + ), + ), + ] else ...[ + const SizedBox(height: 8), + Text( + 'No response', + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ], + ], + ), + ), ), - child: Text( - value, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: color, - fontFamily: 'monospace', + ); + } + + Widget _buildTraceNodeRow( + BuildContext context, TraceLogEntry entry, List repeaters) { + final rxSnrColor = _snrColorFromNullableValue(entry.localSnr); + final rssiColor = _rssiColor(entry.localRssi); + final txSnrColor = _snrColorFromNullableValue(entry.remoteSnr); + final isAmbiguous = _isAmbiguousId(entry.targetRepeaterId, repeaters); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + SizedBox( + width: 70, + child: RepeaterIdChip( + repeaterId: entry.targetRepeaterId, + fontSize: 14, + isAmbiguous: isAmbiguous)), + Expanded( + child: Center( + child: _buildChip( + entry.localSnr?.toStringAsFixed(1) ?? '-', rxSnrColor))), + Expanded( + child: Center( + child: _buildChip( + entry.localRssi != null ? '${entry.localRssi}' : '-', + rssiColor))), + Expanded( + child: Center( + child: _buildChip( + entry.remoteSnr?.toStringAsFixed(1) ?? '-', txSnrColor))), + ], + ), + ); + } + + // --------------------------------------------------------------------------- + // Shared helpers + // --------------------------------------------------------------------------- + + static Widget _buildCardHeader(BuildContext context, PingLogType type, + String timeString, String locationString, + {bool showAmbiguity = false}) { + return Row( + children: [ + _buildTypeBadge(type), + if (showAmbiguity) ...[ + const SizedBox(width: 2), + Tooltip( + message: 'Repeater ID matches multiple nodes', + child: Icon(Icons.help_outline, + size: 14, color: Colors.amber.shade700), + ), + ], + const SizedBox(width: 6), + Text( + timeString, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurface, + ), ), + const Spacer(), + Icon(Icons.location_on, + size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: 2), + Text( + locationString, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontFamily: 'monospace', + ), + ), + ], + ); + } + + static Widget _tableHeader(BuildContext context, String text, + {bool center = false}) { + return Text( + text, + textAlign: center ? TextAlign.center : TextAlign.left, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ); } + + static Color _snrColor(SnrSeverity? severity) { + return switch (severity) { + SnrSeverity.poor => PingColors.signalBad, + SnrSeverity.fair => PingColors.signalMedium, + SnrSeverity.good => PingColors.signalGood, + null => Colors.grey, + }; + } + + static Color _snrColorFromValue(double snr) => PingColors.snrColor(snr); + + static Color _snrColorFromNullableValue(double? snr) { + if (snr == null) return Colors.grey; + return PingColors.snrColor(snr); + } + + static Color _rssiColor(int? rssi) { + if (rssi == null) return Colors.grey; + return PingColors.rssiColor(rssi); + } } -/// Error Log Tab +// ============================================================================= +// Error Log Tab (unchanged) +// ============================================================================= + class _ErrorLogTab extends StatelessWidget { final List entries; @@ -1076,9 +1400,12 @@ class _ErrorLogTab extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.check_circle_outline, size: 48, color: Colors.green), + const Icon(Icons.check_circle_outline, + size: 48, color: Colors.green), const SizedBox(height: 16), - Text('No errors logged', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), + Text('No errors logged', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant)), ], ), ); @@ -1088,7 +1415,7 @@ class _ErrorLogTab extends StatelessWidget { padding: const EdgeInsets.all(16), itemCount: entries.length, itemBuilder: (context, index) { - // Most recent first (no reverse needed - entries already in chronological order) + // Most recent first final entry = entries[entries.length - 1 - index]; return _buildErrorEntry(context, entry); }, diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index cd28c2c5..124c63be 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -6,9 +6,12 @@ import 'package:geolocator/geolocator.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; +import '../models/connection_state.dart'; import '../providers/app_state_provider.dart'; import '../services/permission_disclosure_service.dart'; +import '../services/portal_account_service.dart'; import '../utils/debug_logger_io.dart'; +import '../widgets/app_toast.dart'; import 'home_screen.dart'; import 'log_screen.dart'; import 'connection_screen.dart'; @@ -26,6 +29,9 @@ class MainScaffold extends StatefulWidget { class _MainScaffoldState extends State { int _selectedIndex = 0; bool _hasCheckedDisclosure = false; + bool _hasShownLocationSettingsPrompt = false; + bool _linkPromptDialogOpen = false; + bool _signInErrorToastOpen = false; final List _screens = [ const HomeScreen(), @@ -52,25 +58,23 @@ class _MainScaffoldState extends State { if (kIsWeb) { // Web: No disclosure dialog needed, just request permission // This triggers the browser's native location permission prompt - debugLog('[DISCLOSURE] Web platform - requesting GPS permission directly'); + debugLog( + '[DISCLOSURE] Web platform - requesting GPS permission directly'); await _requestWebGpsPermission(); return; } // Check if disclosure was already shown final hasShown = await PermissionDisclosureService.hasShownDisclosure(); - if (hasShown) { - debugLog('[DISCLOSURE] Already shown, skipping'); - return; + if (!hasShown) { + // Show the disclosure dialog + if (!mounted) return; + debugLog('[DISCLOSURE] Showing location disclosure dialog'); + await PermissionDisclosureService.showLocationDisclosure(context); } - // Show the disclosure dialog - if (!mounted) return; - debugLog('[DISCLOSURE] Showing location disclosure dialog'); - await PermissionDisclosureService.showLocationDisclosure(context); - - debugLog('[DISCLOSURE] User acknowledged, requesting permissions'); - await _requestPermissionsAfterDisclosure(); + debugLog('[DISCLOSURE] Ensuring location permission after disclosure'); + await _ensureLocationPermission(); } /// Request GPS permission on web (triggers browser's native prompt) @@ -93,8 +97,10 @@ class _MainScaffoldState extends State { } } - /// Request permissions after user accepts disclosure - Future _requestPermissionsAfterDisclosure() async { + /// Ensure location permission after disclosure has been shown. + /// Requests when possible, restarts GPS when granted, and surfaces a settings CTA + /// when the permission has been permanently denied. + Future _ensureLocationPermission() async { bool granted = false; if (Platform.isIOS) { @@ -104,12 +110,23 @@ class _MainScaffoldState extends State { permission = await Geolocator.requestPermission(); } debugLog('[DISCLOSURE] iOS location permission: $permission'); + if (permission == LocationPermission.deniedForever) { + _showLocationSettingsPrompt(); + return; + } granted = permission == LocationPermission.always || - permission == LocationPermission.whileInUse; + permission == LocationPermission.whileInUse; } else { - // Android: Request location via permission_handler - final status = await Permission.locationWhenInUse.request(); + // Android: only request if needed so previously granted permission just restarts GPS. + var status = await Permission.locationWhenInUse.status; + if (status.isDenied) { + status = await Permission.locationWhenInUse.request(); + } debugLog('[DISCLOSURE] Android location permission: $status'); + if (status.isPermanentlyDenied) { + _showLocationSettingsPrompt(); + return; + } granted = status.isGranted; } @@ -121,6 +138,150 @@ class _MainScaffoldState extends State { } } + /// One-tap offer to bind the connected radio to the signed-in account. + /// + /// Mirrors the flood-disabled alert: a postFrameCallback plus a local + /// open-guard, because build() runs on every provider notify. + Future _showLinkPrompt() async { + final appState = context.read(); + final deviceName = appState.portalLinkPromptDeviceName ?? 'this device'; + final accountName = appState.portalAccount?.displayName ?? 'your account'; + debugLog('[ACCOUNT] Showing the device link prompt'); + + // try/finally, not a plain assignment: anything that throws between here + // and the reset (a dialog torn down mid-route, a respondToLinkPrompt + // failure) would leave the guard latched TRUE for the life of the screen, + // and the prompt could never be shown again this session. + try { + final accepted = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Link This Device?'), + content: Text( + "Link '$deviceName' to your MeshMapper account ($accountName)? " + 'Wardriving data from this device will count toward your account.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('No thanks'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Link Device'), + ), + ], + ), + ); + + final outcome = await appState.respondToLinkPrompt(accepted ?? false); + // Released BEFORE the outcome is presented, as before: the outcome + // dialog is its own modal and must not hold the prompt guard. + _linkPromptDialogOpen = false; + if (!mounted) return; + await showLinkOutcome(context, outcome); + } finally { + _linkPromptDialogOpen = false; + } + } + + /// Present a link result. Success is a toast; the two cases that need the + /// user to go somewhere else get a dialog. Every other outcome — network, + /// server, unsupported firmware, unauthorized — is deliberately SILENT: + /// linking must never look like a wardriving failure. + static Future showLinkOutcome( + BuildContext context, PortalLinkOutcome outcome) async { + switch (outcome.status) { + case PortalLinkStatus.linked: + AppToast.success( + context, + 'Linked to ${outcome.accountName ?? 'your MeshMapper account'}', + ); + case PortalLinkStatus.adoptionRequired: + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Finish Setup in the Portal'), + content: Text( + 'Your account still has ${outcome.adoptionDeviceCount} ' + 'placeholder device(s). Claiming them moves every one of your ' + 'radios at once, so it has to be done deliberately at ' + 'portal.meshmapper.net. Once that is finished, come back and ' + 'link this device.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('OK'), + ), + ], + ), + ); + case PortalLinkStatus.alreadyLinkedOtherAccount: + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Already Linked Elsewhere'), + content: const Text( + 'This radio is already linked to a different MeshMapper ' + 'account. Unlink it there first, then link it here.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('OK'), + ), + ], + ), + ); + case PortalLinkStatus.skipped: + case PortalLinkStatus.unauthorized: + case PortalLinkStatus.failed: + break; + } + } + + void _showLocationSettingsPrompt() { + if (!mounted || _hasShownLocationSettingsPrompt) return; + _hasShownLocationSettingsPrompt = true; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Location permission is disabled in system settings.'), + action: SnackBarAction( + label: 'Settings', + onPressed: Geolocator.openAppSettings, + ), + ), + ); + } + + /// Report a failed sign-in. + /// + /// The codes are the bounded set `PortalAccountService` emits — the portal's + /// own are already held to `^[a-z_]{1,32}$` by `_sanitizeErrorCode` — but the + /// copy is written here so an unrecognized code can never reach the user as + /// raw text. + void _showPortalSignInError(String code) { + debugLog('[ACCOUNT] Reporting a failed sign-in (reason=$code)'); + // A cancel is a decision, not a failure — it gets the neutral toast. + final isCancel = code == 'denied' || code == 'access_denied'; + final message = switch (code) { + 'denied' || 'access_denied' => 'Sign-in cancelled', + 'expired' => 'That sign-in expired — please try again', + 'timeout' || + 'network' => + 'Could not reach MyMeshMapper — check your connection', + 'code_invalid' => 'That sign-in link was already used — please try again', + _ => 'Sign-in did not finish — please try again', + }; + if (isCancel) { + AppToast.info(context, message); + } else { + AppToast.error(context, message); + } + } + @override Widget build(BuildContext context) { final appState = context.watch(); @@ -149,24 +310,56 @@ class _MainScaffoldState extends State { }); } - // Listen for connection tab requests - switch to Connect tab (e.g. anonymous mode reconnect) - if (appState.requestConnectionTabSwitch && _selectedIndex != 3) { + // MyMeshMapper account link offer (post-connection, non-fatal). + // A pending prompt survives disconnect by design (the provider no-ops + // safely if it is answered afterwards), but never RAISE the dialog for a + // dead connection — the link handshake needs the radio to sign a nonce. + if (appState.isConnected && + appState.portalLinkPromptPending && + !_linkPromptDialogOpen) { + _linkPromptDialogOpen = true; WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() { - _selectedIndex = 3; // Switch to Connect tab - }); - appState.clearConnectionTabSwitchRequest(); - } + if (!mounted) return; + _showLinkPrompt(); }); } - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + // A failed MyMeshMapper sign-in is reported HERE and nowhere else: the + // browser round trip outlives the Settings tap that started it, so by the + // time the callback lands there is no live call site left to answer. The + // scaffold survives the trip whichever tab the user comes back to. + if (appState.portalSignInError != null && !_signInErrorToastOpen) { + _signInErrorToastOpen = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _signInErrorToastOpen = false; + if (!mounted) return; + final code = appState.portalSignInError; + if (code == null) return; + appState.clearPortalSignInError(); + _showPortalSignInError(code); + }); + } + + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; return Scaffold( - body: IndexedStack( - index: _selectedIndex, - children: _screens, + body: Stack( + children: [ + IndexedStack( + index: _selectedIndex, + children: _screens, + ), + if (appState.isAnonymousReconnectInProgress) + Positioned.fill( + child: Container( + color: Colors.black54, + child: Center( + child: _buildAnonymousReconnectOverlay(appState), + ), + ), + ), + ], ), bottomNavigationBar: isLandscape ? _buildCompactNavBar(appState) @@ -174,10 +367,87 @@ class _MainScaffoldState extends State { ); } + Widget _buildAnonymousReconnectOverlay(AppStateProvider appState) { + final enabling = appState.anonymousReconnectEnabling; + final step = appState.connectionStep; + final totalSteps = ConnectionStepExtension.totalSteps; + final progress = step.stepNumber > 0 ? step.stepNumber / totalSteps : 0.0; + + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + decoration: BoxDecoration( + color: const Color(0xFF1E293B), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + color: Colors.orange.shade400, + ), + const SizedBox(height: 20), + Text( + enabling + ? 'Enabling Anonymous Mode...' + : 'Disabling Anonymous Mode...', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.grey.shade100, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: progress, + backgroundColor: Colors.grey.shade800, + color: Colors.orange.shade400, + minHeight: 4, + ), + ), + const SizedBox(height: 10), + Text( + step.description, + style: TextStyle( + color: Colors.grey.shade400, + fontSize: 13, + ), + ), + if (step.stepNumber > 0) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'Step ${step.stepNumber} of $totalSteps', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 12, + ), + ), + ), + ], + ), + ), + ); + } + /// Compact navigation bar for landscape mode (icon-only, shorter height) Widget _buildCompactNavBar(AppStateProvider appState) { + // Pad for the system navigation/gesture bar so the icons aren't hidden + // behind it in landscape; the surface background fills the inset area (#224). return Container( - height: 56, + padding: + EdgeInsets.only(bottom: MediaQuery.of(context).viewPadding.bottom), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, border: Border( @@ -202,13 +472,17 @@ class _MainScaffoldState extends State { showBadge: appState.errorLogEntries.isNotEmpty, ), _buildCompactNavItem( - icon: Icons.show_chart_outlined, - activeIcon: Icons.show_chart, + icon: Icons.history_outlined, + activeIcon: Icons.history, index: 2, ), _buildCompactNavItem( - icon: appState.isConnected ? Icons.bluetooth_connected : Icons.bluetooth, - activeIcon: appState.isConnected ? Icons.bluetooth_connected : Icons.bluetooth, + icon: appState.isConnected + ? Icons.bluetooth_connected + : Icons.bluetooth, + activeIcon: appState.isConnected + ? Icons.bluetooth_connected + : Icons.bluetooth, index: 3, color: appState.isConnected ? Colors.green : null, ), @@ -289,9 +563,9 @@ class _MainScaffoldState extends State { label: 'Log', ), const BottomNavigationBarItem( - icon: Icon(Icons.show_chart_outlined), - activeIcon: Icon(Icons.show_chart), - label: 'Graph', + icon: Icon(Icons.history_outlined), + activeIcon: Icon(Icons.history), + label: 'History', ), BottomNavigationBarItem( icon: Icon( diff --git a/lib/screens/offline_maps_screen.dart b/lib/screens/offline_maps_screen.dart new file mode 100644 index 00000000..66e2cc7c --- /dev/null +++ b/lib/screens/offline_maps_screen.dart @@ -0,0 +1,1677 @@ +import 'dart:async' show unawaited; +import 'dart:math' show Point, max, min; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:provider/provider.dart'; + +import '../providers/app_state_provider.dart'; +import '../services/offline_map_service.dart'; +import '../services/tile_cache_service.dart'; +import '../utils/debug_logger_io.dart'; +import '../utils/geo_validation.dart'; +import '../widgets/app_toast.dart'; +import '../widgets/map_widget.dart' show MapStyle, MapStyleExtension; + +/// Label → URL map for styles the user can download, derived from +/// [MapStyle.downloadable]. Satellite is excluded (inline raster JSON +/// doesn't work with MapLibre's offline region downloader). +final Map _downloadStyles = { + for (final s in MapStyleExtension.downloadable) s.label: s.styleUrl, +}; + +/// Screen for managing offline map tile downloads. +/// +/// Accessible from the Settings screen. The underlying [OfflineMapService] +/// lives at the app level (via Provider), so downloads continue even after +/// navigating away from this screen. A system notification shows progress. +class OfflineMapsScreen extends StatefulWidget { + const OfflineMapsScreen({super.key}); + + @override + State createState() => _OfflineMapsScreenState(); +} + +class _OfflineMapsScreenState extends State { + bool _tileCacheBusy = false; + + @override + void initState() { + super.initState(); + // Listen for background download completions to show a toast. + final service = context.read(); + service.addListener(_onServiceUpdate); + // Pull fresh per-region + total cache sizes so the storage bar reflects + // current on-disk reality (including any ambient-cache growth since the + // service last refreshed). + service.refreshRegions(); + } + + @override + void dispose() { + // Use try-catch in case the provider is already disposed during app teardown. + try { + context.read().removeListener(_onServiceUpdate); + } catch (_) {} + super.dispose(); + } + + void _onServiceUpdate() { + if (!mounted) return; + final service = context.read(); + final completed = service.consumeLastCompletedName(); + if (completed != null) { + AppToast.success(context, '"$completed" downloaded'); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final service = context.watch(); + + return Scaffold( + appBar: AppBar( + toolbarHeight: 40, + title: const Text('Offline Maps', style: TextStyle(fontSize: 18)), + ), + body: !service.initialized + ? const Center(child: CircularProgressIndicator()) + : kIsWeb + ? _buildWebUnsupported(theme) + : ListView( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), + children: [ + _buildStorageCard(context, service, theme, isDark), + const SizedBox(height: 8), + _buildDownloadedRegionsCard( + context, service, theme, isDark), + const SizedBox(height: 8), + if (service.isDownloading) + _buildDownloadProgressCard( + context, service, theme, isDark), + ], + ), + floatingActionButton: + (service.initialized && !kIsWeb && !service.isDownloading) + ? FloatingActionButton.extended( + heroTag: null, + onPressed: () => _showDownloadDialog(context), + icon: const Icon(Icons.download), + label: const Text('Download Area'), + ) + : null, + ); + } + + Widget _buildWebUnsupported(ThemeData theme) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.cloud_off, size: 64, color: Colors.grey.shade400), + const SizedBox(height: 16), + Text( + 'Offline maps are not available on web', + style: theme.textTheme.titleMedium + ?.copyWith(color: Colors.grey.shade500), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Use the mobile app to download map areas for offline use', + style: theme.textTheme.bodySmall + ?.copyWith(color: Colors.grey.shade400), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + // ────────────────────────────────────────────── + // Storage usage card + // ────────────────────────────────────────────── + + Widget _buildStorageCard(BuildContext context, OfflineMapService service, + ThemeData theme, bool isDark) { + final downloadsRatio = service.downloadsRatio; + final ambientRatio = service.ambientRatio; + final usageRatio = service.usageRatio; + + // Over-quota colors the total-used text red; the bar itself stays + // segmented so the user can still see which bucket is driving the limit. + final overQuota = usageRatio >= 1.0; + final totalColor = overQuota + ? Colors.red + : (usageRatio > 0.9 + ? Colors.red + : (usageRatio > 0.7 ? Colors.orange : theme.colorScheme.primary)); + + final downloadsColor = theme.colorScheme.primary; + final ambientColor = theme.colorScheme.tertiary; + final trackColor = + isDark ? Colors.white.withValues(alpha: 0.08) : Colors.grey.shade200; + + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Storage', + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.refresh, size: 18), + onPressed: + _tileCacheBusy ? null : () => service.refreshRegions(), + tooltip: 'Refresh sizes', + visualDensity: VisualDensity.compact, + ), + TextButton.icon( + onPressed: () => _showStorageLimitDialog(context, service), + icon: const Icon(Icons.tune, size: 16), + label: const Text('Limit'), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + ), + ], + ), + const SizedBox(height: 12), + + // Segmented usage bar: downloads (primary) + ambient (tertiary) + // stacked left-to-right against the storage limit. + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox( + height: 20, + child: LayoutBuilder( + builder: (context, constraints) { + final total = constraints.maxWidth; + final downloadsWidth = + (downloadsRatio.clamp(0.0, 1.0)) * total; + // Ambient is clamped so the two segments can never exceed + // the track width even if the real total briefly overshoots. + final ambientWidth = + ((ambientRatio.clamp(0.0, 1.0)) * total) + .clamp(0.0, total - downloadsWidth); + return Stack( + children: [ + Positioned.fill(child: Container(color: trackColor)), + Positioned( + left: 0, + top: 0, + bottom: 0, + width: downloadsWidth, + child: Container(color: downloadsColor), + ), + Positioned( + left: downloadsWidth, + top: 0, + bottom: 0, + width: ambientWidth, + child: Container(color: ambientColor), + ), + ], + ); + }, + ), + ), + ), + const SizedBox(height: 8), + + // Totals + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${service.totalUsedDisplay} used', + style: theme.textTheme.bodySmall?.copyWith( + color: totalColor, + fontWeight: FontWeight.w600, + ), + ), + Text( + '${service.storageLimitDisplay} limit', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.grey, + ), + ), + ], + ), + const SizedBox(height: 10), + + // Legend + _buildLegendRow( + theme, + color: downloadsColor, + label: 'Downloads', + value: service.downloadsDisplay, + sublabel: '${service.regions.length} ' + 'area${service.regions.length == 1 ? '' : 's'}', + ), + const SizedBox(height: 4), + _buildLegendRow( + theme, + color: ambientColor, + label: 'Ambient cache', + value: service.ambientDisplay, + sublabel: 'auto-cached while panning', + ), + + const SizedBox(height: 12), + + // Ambient cache actions (downloads are managed below). + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _tileCacheBusy ? null : _onInvalidateTileCache, + icon: const Icon(Icons.refresh_outlined, size: 18), + label: const Text('Invalidate cache'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: _tileCacheBusy ? null : _onClearTileCache, + icon: const Icon(Icons.delete_sweep_outlined, size: 18), + label: const Text('Clear cache'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildLegendRow( + ThemeData theme, { + required Color color, + required String label, + required String value, + required String sublabel, + }) { + return Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + '· $sublabel', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.grey.shade500, + fontSize: 11, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + value, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } + + Future _onInvalidateTileCache() async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Invalidate tile cache?'), + content: const Text( + 'Marks cached tiles as stale so they refresh on next view. ' + 'Downloaded areas are not affected.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('Invalidate'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _tileCacheBusy = true); + try { + await TileCacheService.instance.invalidateAmbientCache(); + if (!mounted) return; + AppToast.success(context, 'Tile cache invalidated'); + } catch (e) { + if (!mounted) return; + AppToast.error(context, 'Invalidate failed: $e'); + } finally { + if (mounted) setState(() => _tileCacheBusy = false); + if (mounted) await context.read().refreshCacheSize(); + } + } + + Future _onClearTileCache() async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Clear tile cache?'), + content: const Text( + 'Removes opportunistically cached tiles from disk. ' + 'Downloaded areas are preserved.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Cancel'), + ), + TextButton( + style: TextButton.styleFrom(foregroundColor: Colors.red), + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('Clear'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _tileCacheBusy = true); + try { + await TileCacheService.instance.clearAmbientCache(); + if (!mounted) return; + AppToast.success(context, 'Tile cache cleared'); + } catch (e) { + if (!mounted) return; + AppToast.error(context, 'Clear failed: $e'); + } finally { + if (mounted) setState(() => _tileCacheBusy = false); + if (mounted) await context.read().refreshCacheSize(); + } + } + + // ────────────────────────────────────────────── + // Downloaded regions list + // ────────────────────────────────────────────── + + Widget _buildDownloadedRegionsCard(BuildContext context, + OfflineMapService service, ThemeData theme, bool isDark) { + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 0), + child: Row( + children: [ + Text( + 'Downloaded Areas', + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + if (service.regions.isNotEmpty) + IconButton( + icon: const Icon(Icons.refresh, size: 20), + onPressed: () => service.refreshRegions(), + tooltip: 'Refresh', + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + if (service.regions.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Column( + children: [ + Icon(Icons.map_outlined, + size: 48, color: Colors.grey.shade400), + const SizedBox(height: 8), + Text( + 'No offline areas downloaded', + style: TextStyle(color: Colors.grey.shade500), + ), + const SizedBox(height: 4), + Text( + 'Tap "Download Area" to save map tiles for offline use', + style: TextStyle(fontSize: 12, color: Colors.grey.shade400), + textAlign: TextAlign.center, + ), + ], + ), + ) + else + ...service.regions.map( + (region) => _RegionTile( + region: region, + onDelete: () => _confirmDeleteRegion(context, service, region), + ), + ), + if (service.regions.length > 1) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: OutlinedButton.icon( + onPressed: () => _confirmDeleteAll(context, service), + icon: const Icon(Icons.delete_sweep, size: 18), + label: const Text('Delete All'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + side: const BorderSide(color: Colors.red, width: 0.5), + minimumSize: const Size.fromHeight(36), + visualDensity: VisualDensity.compact, + ), + ), + ), + const SizedBox(height: 4), + ], + ), + ); + } + + // ────────────────────────────────────────────── + // Download progress card + // ────────────────────────────────────────────── + + Widget _buildDownloadProgressCard(BuildContext context, + OfflineMapService service, ThemeData theme, bool isDark) { + final progress = service.downloadProgress ?? 0; + final isPreparing = progress == 0; + final speed = service.downloadBytesPerSecond; + final eta = service.downloadEta; + + String? stats; + if (!isPreparing) { + final parts = []; + if (speed != null) parts.add(_formatSpeed(speed)); + if (eta != null) parts.add('${_formatDuration(eta)} remaining'); + if (parts.isNotEmpty) stats = parts.join(' · '); + } + + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Downloading', + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + service.downloadingRegionName ?? 'Area', + style: theme.textTheme.bodyMedium, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: isPreparing ? null : progress, + minHeight: 8, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Text( + isPreparing ? '—' : '${(progress * 100).round()}%', + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + isPreparing + ? 'Preparing download…' + : (stats ?? 'Calculating speed…'), + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.grey.shade600, + fontSize: 12, + ), + ), + const SizedBox(height: 6), + Text( + 'Download continues in the background if you leave this screen', + style: TextStyle(fontSize: 11, color: Colors.grey.shade500), + ), + if (service.queueLength > 0) ...[ + const SizedBox(height: 4), + Text( + '${service.queueLength} more queued after this', + style: TextStyle( + fontSize: 11, + color: Colors.grey.shade500, + fontStyle: FontStyle.italic, + ), + ), + ], + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: () => _confirmCancelDownload(context, service), + icon: const Icon(Icons.close, size: 16, color: Colors.red), + label: + const Text('Cancel', style: TextStyle(color: Colors.red)), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + ), + ), + ], + ), + ), + ); + } + + /// Formats bytes/sec into a short display string (e.g. "1.2 MB/s"). + String _formatSpeed(double bytesPerSecond) { + if (bytesPerSecond < 1024) { + return '${bytesPerSecond.toStringAsFixed(0)} B/s'; + } + if (bytesPerSecond < 1024 * 1024) { + return '${(bytesPerSecond / 1024).toStringAsFixed(1)} KB/s'; + } + return '${(bytesPerSecond / (1024 * 1024)).toStringAsFixed(1)} MB/s'; + } + + /// Formats a Duration as a short human-readable string (e.g. "4m 20s"). + String _formatDuration(Duration d) { + if (d.inSeconds < 60) return '${d.inSeconds}s'; + if (d.inMinutes < 60) { + final s = d.inSeconds % 60; + return s == 0 ? '${d.inMinutes}m' : '${d.inMinutes}m ${s}s'; + } + final minutes = d.inMinutes % 60; + return minutes == 0 ? '${d.inHours}h' : '${d.inHours}h ${minutes}m'; + } + + Future _confirmCancelDownload( + BuildContext context, OfflineMapService service) async { + final name = service.downloadingRegionName ?? 'this download'; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Cancel download?'), + content: Text( + 'Tiles downloaded so far for "$name" will be discarded. ' + 'Queued downloads (if any) will continue automatically.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Keep downloading'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Cancel download'), + ), + ], + ), + ); + if (confirmed != true) return; + final ok = await service.cancelActiveDownload(); + if (context.mounted && ok) { + AppToast.info(context, 'Download cancelled'); + } + } + + // ────────────────────────────────────────────── + // Storage limit dialog + // ────────────────────────────────────────────── + + Future _showStorageLimitDialog( + BuildContext context, OfflineMapService service) async { + int currentLimit = service.storageLimitMb; + + final result = await showDialog( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setDialogState) { + return AlertDialog( + title: const Text('Storage Limit'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$currentLimit MB', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(height: 16), + Slider( + value: currentLimit.toDouble(), + min: OfflineMapService.minStorageLimitMb.toDouble(), + max: OfflineMapService.maxStorageLimitMb.toDouble(), + divisions: (OfflineMapService.maxStorageLimitMb - + OfflineMapService.minStorageLimitMb) ~/ + 50, + label: '$currentLimit MB', + onChanged: (value) { + setDialogState(() => currentLimit = value.round()); + }, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${OfflineMapService.minStorageLimitMb} MB', + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + '${OfflineMapService.maxStorageLimitMb} MB', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Currently using ${service.totalUsedDisplay}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, currentLimit), + child: const Text('Save'), + ), + ], + ); + }, + ); + }, + ); + + if (result != null) { + await service.setStorageLimit(result); + if (context.mounted) { + AppToast.success(context, 'Storage limit set to $result MB'); + } + } + } + + // ────────────────────────────────────────────── + // Download new region dialog + // ────────────────────────────────────────────── + + Future _showDownloadDialog(BuildContext context) async { + final started = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const _DownloadRegionPage(), + ), + ); + + // Toast is handled by the _onServiceUpdate listener when the download + // completes (which may happen long after this page returns). + if (started == true && context.mounted) { + AppToast.simple( + context, 'Download started — check notifications for progress'); + } + } + + // ────────────────────────────────────────────── + // Delete confirmations + // ────────────────────────────────────────────── + + Future _confirmDeleteRegion(BuildContext context, + OfflineMapService service, OfflineMapRegion region) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete Area?'), + content: Text( + 'Delete "${region.name}"? This will free ' + '${region.sizeDisplay} of storage.\n\n' + 'Note: shared tiles used by other areas may not be freed immediately.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed == true) { + final success = await service.deleteRegion(region.id); + if (context.mounted) { + if (success) { + AppToast.success(context, '"${region.name}" deleted'); + } else { + AppToast.error( + context, service.consumeLastError() ?? 'Failed to delete area'); + } + } + } + } + + Future _confirmDeleteAll( + BuildContext context, OfflineMapService service) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete All Areas?'), + content: Text( + 'Delete all ${service.regions.length} downloaded areas? ' + 'This will free ${service.downloadsDisplay} of downloaded tiles ' + '(ambient cache is preserved).', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Delete All'), + ), + ], + ), + ); + + if (confirmed == true) { + await service.deleteAllRegions(); + if (context.mounted) { + AppToast.success(context, 'All areas deleted'); + } + } + } +} + +// ═══════════════════════════════════════════════ +// Region list tile +// ═══════════════════════════════════════════════ + +class _RegionTile extends StatelessWidget { + final OfflineMapRegion region; + final VoidCallback onDelete; + + const _RegionTile({required this.region, required this.onDelete}); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: _styleIcon(region.styleName), + title: Text( + region.name, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '${region.styleName} · z${region.minZoom.round()}-${region.maxZoom.round()} · ${region.sizeDisplay}\n' + '${region.boundsDisplay}', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + isThreeLine: true, + trailing: IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.red, size: 20), + onPressed: onDelete, + tooltip: 'Delete', + ), + ); + } + + Widget _styleIcon(String styleName) { + switch (styleName.toLowerCase()) { + case 'dark': + return const Icon(Icons.dark_mode); + case 'light': + return const Icon(Icons.light_mode); + case 'satellite': + return const Icon(Icons.satellite_alt); + case 'liberty': + default: + return const Icon(Icons.map); + } + } +} + +// ═══════════════════════════════════════════════ +// Download region flow (full-page) +// ═══════════════════════════════════════════════ + +class _DownloadRegionPage extends StatefulWidget { + const _DownloadRegionPage(); + + @override + State<_DownloadRegionPage> createState() => _DownloadRegionPageState(); +} + +class _DownloadRegionPageState extends State<_DownloadRegionPage> { + final _nameController = TextEditingController(); + final _nameFocus = FocusNode(); + String _selectedStyle = 'Liberty'; + double _minZoom = 6; + double _maxZoom = 14; + bool _submitting = false; + String? _error; + String? _nameError; + + // Default center (Ottawa) + static const LatLng _defaultCenter = LatLng(45.4215, -75.6972); + + // Bounds selection via interactive map + MapLibreMapController? _mapController; + LatLng? _boundsNE; + LatLng? _boundsSW; + Line? _boundsLine; + Fill? _boundsFill; + + // Draggable corner resize handles. Each corner has two stacked circles: + // a larger invisible-ish "halo" below and a smaller visible handle on top. + // Both are draggable so fingers landing on the halo still start a drag — + // maplibre's iOS pan gesture only hit-tests after crossing its activation + // threshold (~10pt of movement), so the touch target has to be bigger than + // the visual. `_handleIdToCorner` maps each Circle id back to its corner + // so onFeatureDrag resolves either shape to the correct bounds update. + final Map> _cornerHandles = {}; + final Map _handleIdToCorner = {}; + + // Existing region overlays + final List _existingFills = []; + final List _existingLines = []; + bool _showExisting = true; + + @override + void initState() { + super.initState(); + // grab user's current map style to start + final pref = context.read().preferences.mapStyle; + final mapped = pref.substring(0, 1).toUpperCase() + pref.substring(1); + if (_downloadStyles.containsKey(mapped)) { + _selectedStyle = mapped; + } + } + + @override + void dispose() { + _nameController.dispose(); + _nameFocus.dispose(); + super.dispose(); + } + + LatLngBounds? get _selectedBounds { + if (_boundsNE == null || _boundsSW == null) return null; + // Normalize so tile estimates stay valid mid-drag when a corner has been + // pulled past its opposite (stored SW/NE may be inverted). + final sw = LatLng( + min(_boundsSW!.latitude, _boundsNE!.latitude), + min(_boundsSW!.longitude, _boundsNE!.longitude), + ); + final ne = LatLng( + max(_boundsSW!.latitude, _boundsNE!.latitude), + max(_boundsSW!.longitude, _boundsNE!.longitude), + ); + return LatLngBounds(southwest: sw, northeast: ne); + } + + // Stays live whenever a download isn't already starting. Gating this on a + // name/bounds being present left the button greyed out with nothing saying + // why, so users read it as broken; `_startDownload` names what's missing. + bool get _canSubmit => !_submitting; + + int get _estimatedTiles { + final bounds = _selectedBounds; + if (bounds == null) return 0; + return OfflineMapService.estimateTileCount(bounds, _minZoom, _maxZoom); + } + + String get _estimatedSize { + final bytes = OfflineMapService.estimateSizeBytes(_estimatedTiles); + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(0)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + + @override + Widget build(BuildContext context) { + final appState = context.watch(); + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + // Determine map center - prefer current GPS, fallback to last known, then + // Ottawa. Only adopt a source position when its coords are valid — this + // feeds initialCameraPosition, and an invalid LatLng aborts the app in + // MapLibre's native ctor. + LatLng center = _defaultCenter; + final pos = appState.currentPosition; + final lastKnown = appState.lastKnownPosition; + if (pos != null && isValidLatLng(pos.latitude, pos.longitude)) { + center = LatLng(pos.latitude, pos.longitude); + } else if (lastKnown != null && + isValidLatLng(lastKnown.lat, lastKnown.lon)) { + center = LatLng(lastKnown.lat, lastKnown.lon); + } + + return Scaffold( + appBar: AppBar( + toolbarHeight: 40, + title: const Text('Download Area', style: TextStyle(fontSize: 18)), + ), + body: Column( + children: [ + // Map for bounds selection + Expanded( + flex: 3, + child: Stack( + children: [ + MapLibreMap( + styleString: _downloadStyles[_selectedStyle]!, + initialCameraPosition: CameraPosition( + target: center, // Vancouver default + zoom: 10, + ), + onMapCreated: (controller) { + _mapController = controller; + controller.onFeatureDrag.add(_onFeatureDrag); + }, + onStyleLoadedCallback: () { + if (_showExisting) _drawExistingRegions(); + }, + onMapClick: _onMapTap, + rotateGesturesEnabled: false, + tiltGesturesEnabled: false, + ), + // Bounds instruction overlay + Positioned( + top: 8, + left: 8, + right: 8, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: (isDark ? Colors.black : Colors.white) + .withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _selectedBounds != null + ? 'Drag corners to resize · ~$_estimatedTiles tiles · $_estimatedSize' + : 'Tap the map to place an area', + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + ), + // Reset bounds button + if (_selectedBounds != null) + Positioned( + top: 8, + right: 8, + child: Material( + type: MaterialType.circle, + color: theme.colorScheme.primaryContainer, + elevation: 2, + child: InkWell( + customBorder: const CircleBorder(), + onTap: _resetBounds, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon(Icons.restart_alt, + size: 20, + color: theme.colorScheme.onPrimaryContainer), + ), + ), + ), + ), + // Existing regions toggle + Positioned( + bottom: 8, + left: 8, + child: Material( + borderRadius: BorderRadius.circular(16), + color: (isDark ? Colors.black : Colors.white) + .withValues(alpha: 0.85), + elevation: 2, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: _toggleExistingRegions, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _showExisting + ? Icons.visibility + : Icons.visibility_off, + size: 14, + color: const Color(0xFFF59E0B), + ), + const SizedBox(width: 4), + Text( + '${context.read().regions.length} existing', + style: theme.textTheme.bodySmall?.copyWith( + fontSize: 11, + color: _showExisting + ? const Color(0xFFF59E0B) + : Colors.grey, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + + // Configuration panel + Expanded( + flex: 2, + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Region name + TextField( + controller: _nameController, + focusNode: _nameFocus, + decoration: InputDecoration( + labelText: 'Area Name *', + hintText: 'e.g. Downtown Vancouver', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8)), + isDense: true, + prefixIcon: const Icon(Icons.label_outline, size: 20), + errorText: _nameError, + ), + onChanged: (value) { + setState(() { + if (_nameError != null && value.trim().isNotEmpty) { + _nameError = null; + } + }); + }, + ), + const SizedBox(height: 12), + + // Style selector + Row( + children: [ + const Text('Style: ', + style: TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(width: 8), + Expanded( + child: SegmentedButton( + segments: _downloadStyles.keys + .map((s) => ButtonSegment( + value: s, + label: Text(s, + style: const TextStyle(fontSize: 12)), + )) + .toList(), + selected: {_selectedStyle}, + onSelectionChanged: (selected) { + setState(() => _selectedStyle = selected.first); + }, + style: SegmentedButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + + // Zoom range + Row( + children: [ + Text( + 'Zoom: ${_minZoom.round()} – ${_maxZoom.round()}', + style: const TextStyle(fontWeight: FontWeight.w500), + ), + const Spacer(), + Text( + '~$_estimatedTiles tiles', + style: TextStyle( + fontSize: 12, color: Colors.grey.shade500), + ), + ], + ), + RangeSlider( + values: RangeValues(_minZoom, _maxZoom), + // Min matches the app's MapLibreMap minMaxZoomPreference + // floor (3) so users can't cache tiles the app won't + // display. Max stays at 15: OpenFreeMap vector tiles max + // out at z14, z15+ is pure overzoom (duplicate tile data), + // so 15 leaves one overzoom step reachable without + // blowing out storage. + min: 3, + max: 15, + divisions: 12, + labels: RangeLabels( + _minZoom.round().toString(), + _maxZoom.round().toString(), + ), + onChanged: (values) { + setState(() { + _minZoom = values.start; + _maxZoom = values.end; + }); + }, + ), + + if (_error != null) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(8), + 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( + _error!, + style: const TextStyle(color: Colors.red, fontSize: 12), + ), + ), + ], + + const SizedBox(height: 12), + + // Download button + FilledButton.icon( + onPressed: _canSubmit ? _startDownload : null, + icon: _submitting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.download), + label: Text(_submitting ? 'Starting...' : 'Download Area'), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(44), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Future _onMapTap(Point point, LatLng coordinates) async { + // Once a region exists, taps on the map do nothing — user resizes via + // the corner handles or resets to start over. + if (_boundsSW != null && _boundsNE != null) return; + if (_mapController == null) return; + + // Size the default box to ~40% of the visible map area centered on the + // tap, so the result feels proportional regardless of zoom level. + LatLngBounds visible; + try { + visible = await _mapController!.getVisibleRegion(); + } catch (e) { + debugWarn('[OFFLINE_MAP] getVisibleRegion failed: $e'); + return; + } + + final halfLat = + (visible.northeast.latitude - visible.southwest.latitude).abs() * 0.2; + final halfLng = + (visible.northeast.longitude - visible.southwest.longitude).abs() * 0.2; + + final sw = LatLng( + coordinates.latitude - halfLat, + coordinates.longitude - halfLng, + ); + final ne = LatLng( + coordinates.latitude + halfLat, + coordinates.longitude + halfLng, + ); + + if ((ne.longitude - sw.longitude).abs() > 180) { + setState(() { + _error = 'Visible area spans more than half the globe. ' + 'Zoom in before placing an area.'; + }); + return; + } + + setState(() { + _boundsSW = sw; + _boundsNE = ne; + _error = null; + }); + await _drawBoundsOverlay(); + if (mounted) setState(() {}); + } + + void _resetBounds() { + _clearBoundsOverlay(); + setState(() { + _boundsSW = null; + _boundsNE = null; + }); + } + + /// Draw outlines for all previously downloaded regions so the user + /// can see existing coverage while selecting a new area. + Future _drawExistingRegions() async { + if (_mapController == null) return; + final service = context.read(); + for (final region in service.regions) { + try { + final sw = region.bounds.southwest; + final ne = region.bounds.northeast; + final nw = LatLng(ne.latitude, sw.longitude); + final se = LatLng(sw.latitude, ne.longitude); + final ring = [sw, se, ne, nw, sw]; + + final fill = await _mapController!.addFill(FillOptions( + geometry: [ring], + fillColor: '#F59E0B', // amber-500 + fillOpacity: 0.10, + )); + final line = await _mapController!.addLine(LineOptions( + geometry: ring, + lineColor: '#F59E0B', + lineWidth: 1.5, + lineOpacity: 0.6, + )); + _existingFills.add(fill); + _existingLines.add(line); + } catch (e) { + debugWarn( + '[OFFLINE_MAP] Failed to draw existing region ${region.name}: $e'); + } + } + } + + Future _clearExistingRegions() async { + if (_mapController == null) return; + for (final f in _existingFills) { + try { + await _mapController!.removeFill(f); + } catch (_) {} + } + for (final l in _existingLines) { + try { + await _mapController!.removeLine(l); + } catch (_) {} + } + _existingFills.clear(); + _existingLines.clear(); + } + + void _toggleExistingRegions() { + setState(() => _showExisting = !_showExisting); + if (_showExisting) { + _drawExistingRegions(); + } else { + _clearExistingRegions(); + } + } + + Future _drawBoundsOverlay() async { + if (_mapController == null || _boundsSW == null || _boundsNE == null) { + return; + } + + final corners = _cornerPositions(); + final ring = [ + corners['SW']!, + corners['SE']!, + corners['NE']!, + corners['NW']!, + corners['SW']!, + ]; + + try { + _boundsFill = await _mapController!.addFill(FillOptions( + geometry: [ring], + fillColor: '#4A90D9', + fillOpacity: 0.15, + )); + _boundsLine = await _mapController!.addLine(LineOptions( + geometry: ring, + lineColor: '#4A90D9', + lineWidth: 2.0, + lineOpacity: 0.8, + )); + for (final entry in corners.entries) { + // Halo first so it renders below the visible handle. ~44pt diameter + // gives the finger a comfortable touch target even after iOS's pan + // activation threshold eats ~10pt of travel before hit-testing. + final halo = await _mapController!.addCircle(CircleOptions( + geometry: entry.value, + circleRadius: 22, + circleColor: '#4A90D9', + circleOpacity: 0.18, + draggable: true, + )); + final handle = await _mapController!.addCircle(CircleOptions( + geometry: entry.value, + circleRadius: 7, + circleColor: '#FFFFFF', + circleStrokeColor: '#4A90D9', + circleStrokeWidth: 2.5, + draggable: true, + )); + _cornerHandles[entry.key] = [halo, handle]; + _handleIdToCorner[halo.id] = entry.key; + _handleIdToCorner[handle.id] = entry.key; + } + } catch (e) { + debugWarn('[OFFLINE_MAP] Failed to draw bounds overlay: $e'); + } + } + + Future _clearBoundsOverlay() async { + if (_mapController == null) return; + try { + if (_boundsFill != null) { + await _mapController!.removeFill(_boundsFill!); + _boundsFill = null; + } + if (_boundsLine != null) { + await _mapController!.removeLine(_boundsLine!); + _boundsLine = null; + } + for (final circles in _cornerHandles.values) { + for (final circle in circles) { + try { + await _mapController!.removeCircle(circle); + } catch (_) {} + } + } + _cornerHandles.clear(); + _handleIdToCorner.clear(); + } catch (e) { + debugWarn('[OFFLINE_MAP] Failed to clear bounds overlay: $e'); + } + } + + /// The four visual corner LatLngs derived from the stored SW/NE pair. + /// Uses raw (non-normalized) values so handle semantics stay stable + /// mid-drag; the polygon wraps correctly either way. + Map _cornerPositions() { + final sw = _boundsSW!; + final ne = _boundsNE!; + return { + 'SW': sw, + 'SE': LatLng(sw.latitude, ne.longitude), + 'NE': ne, + 'NW': LatLng(ne.latitude, sw.longitude), + }; + } + + Future _refreshBoundsGeometry() async { + if (_mapController == null || + _boundsFill == null || + _boundsLine == null || + _boundsSW == null || + _boundsNE == null) { + return; + } + final corners = _cornerPositions(); + final ring = [ + corners['SW']!, + corners['SE']!, + corners['NE']!, + corners['NW']!, + corners['SW']!, + ]; + try { + await _mapController! + .updateFill(_boundsFill!, FillOptions(geometry: [ring])); + await _mapController! + .updateLine(_boundsLine!, LineOptions(geometry: ring)); + } catch (e) { + debugWarn('[OFFLINE_MAP] Failed to refresh bounds geometry: $e'); + } + } + + /// Repositions every handle circle except the one maplibre is already + /// dragging. Each corner has a halo + visible pair, and the non-dragged + /// circle in the dragged corner still needs to follow its partner. + Future _refreshHandles({String? skipId}) async { + if (_mapController == null) return; + final corners = _cornerPositions(); + for (final entry in corners.entries) { + final circles = _cornerHandles[entry.key]; + if (circles == null) continue; + for (final circle in circles) { + if (circle.id == skipId) continue; + try { + await _mapController! + .updateCircle(circle, CircleOptions(geometry: entry.value)); + } catch (e) { + debugWarn('[OFFLINE_MAP] Failed to update ${entry.key} handle: $e'); + } + } + } + } + + void _onFeatureDrag( + Point point, + LatLng origin, + LatLng current, + LatLng delta, + String id, + Annotation? annotation, + DragEventType eventType, + ) { + final corner = _handleIdToCorner[id]; + if (corner == null || _boundsSW == null || _boundsNE == null) { + return; + } + + switch (corner) { + case 'SW': + _boundsSW = current; + break; + case 'NE': + _boundsNE = current; + break; + case 'NW': + _boundsSW = LatLng(_boundsSW!.latitude, current.longitude); + _boundsNE = LatLng(current.latitude, _boundsNE!.longitude); + break; + case 'SE': + _boundsSW = LatLng(current.latitude, _boundsSW!.longitude); + _boundsNE = LatLng(_boundsNE!.latitude, current.longitude); + break; + } + + _refreshBoundsGeometry(); + _refreshHandles(skipId: id); + + if (eventType == DragEventType.end) { + // Snap stored SW/NE to normalized orientation and realign every + // handle (including the one just released) to its semantic corner. + final sw = LatLng( + min(_boundsSW!.latitude, _boundsNE!.latitude), + min(_boundsSW!.longitude, _boundsNE!.longitude), + ); + final ne = LatLng( + max(_boundsSW!.latitude, _boundsNE!.latitude), + max(_boundsSW!.longitude, _boundsNE!.longitude), + ); + _boundsSW = sw; + _boundsNE = ne; + + if ((ne.longitude - sw.longitude).abs() > 180) { + _error = 'Selected area crosses the antimeridian. ' + 'Split into two areas (one per hemisphere).'; + } else { + _error = null; + } + + _refreshHandles(); + _refreshBoundsGeometry(); + } + + if (mounted) setState(() {}); + } + + Future _startDownload() async { + // Both of these used to be silent early returns behind a disabled button, + // so a user with an unnamed area had no way to tell what was wrong. + final bounds = _selectedBounds; + if (bounds == null) { + setState(() => _error = 'Tap the map to place an area first.'); + return; + } + + final name = _nameController.text.trim(); + if (name.isEmpty) { + setState(() => _nameError = 'Enter a name for this area'); + _nameFocus.requestFocus(); + return; + } + + final service = context.read(); + final styleUrl = _downloadStyles[_selectedStyle]!; + + // Check storage limit + final estBytes = OfflineMapService.estimateSizeBytes(_estimatedTiles); + if (service.wouldExceedLimit(estBytes)) { + setState(() { + _error = 'This download would exceed your storage limit. ' + 'Free up space or increase the limit in storage settings.'; + }); + return; + } + + setState(() { + _submitting = true; + _error = null; + }); + + // Kick off the download without awaiting the completer — that future + // resolves only on full completion (minutes later). We just need the + // download to reach the "active" state so we can pop back to the main + // screen where the progress card is visible. + unawaited(service.downloadRegion( + name: name, + bounds: bounds, + styleUrl: styleUrl, + styleName: _selectedStyle, + minZoom: _minZoom, + maxZoom: _maxZoom, + )); + + // Wait briefly for the service to enter `isDownloading` or surface an + // early validation error (free-space, quota). The pre-download async + // checks complete in well under a second; cap at 2s to stay responsive. + final stopwatch = Stopwatch()..start(); + while (mounted && + stopwatch.elapsed < const Duration(seconds: 2) && + !service.isDownloading && + service.lastError == null) { + await Future.delayed(const Duration(milliseconds: 100)); + } + + if (!mounted) return; + + if (service.lastError != null) { + setState(() { + _submitting = false; + _error = service.consumeLastError(); + }); + return; + } + + // Either actively downloading, still starting, or queued behind another — + // in every case the user should see the main screen's progress card. + Navigator.pop(context, true); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 77e6378b..0c23e788 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,7 +1,9 @@ import 'dart:convert'; import 'dart:io' show File; +import 'dart:math' as math; -import 'package:flutter/foundation.dart' show kIsWeb, defaultTargetPlatform, TargetPlatform; +import 'package:flutter/foundation.dart' + show kIsWeb, defaultTargetPlatform, TargetPlatform; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -23,11 +25,14 @@ import '../services/debug_file_logger.dart'; import '../services/gps_simulator_service.dart'; import '../services/offline_session_service.dart'; import '../services/permission_disclosure_service.dart'; +import '../services/portal_account_service.dart'; import '../utils/constants.dart'; import '../widgets/bug_report_dialog.dart'; import '../widgets/upload_logs_dialog.dart'; import 'package:intl/intl.dart'; import '../widgets/app_toast.dart'; +import 'offline_maps_screen.dart'; +import 'watch_diagnostics_screen.dart'; /// Settings screen for user preferences and API configuration class SettingsScreen extends StatefulWidget { @@ -42,13 +47,15 @@ class _SettingsScreenState extends State { int _versionTapCount = 0; DateTime? _lastVersionTap; - Future _showUploadLogsDialog(BuildContext context, AppStateProvider appState) async { + Future _showUploadLogsDialog( + BuildContext context, AppStateProvider appState) async { final result = await showUploadLogsDialog(context, appState); if (!context.mounted || result == null) return; if (result.success) { - String message = 'Uploaded ${result.uploadedCount} log file${result.uploadedCount == 1 ? '' : 's'}'; + String message = + 'Uploaded ${result.uploadedCount} log file${result.uploadedCount == 1 ? '' : 's'}'; if (result.failedCount > 0) { message += ' (${result.failedCount} failed)'; } @@ -102,709 +109,1474 @@ class _SettingsScreenState extends State { return Scaffold( appBar: AppBar( - title: const Text('Settings'), + toolbarHeight: 40, + title: const Text('Settings', style: TextStyle(fontSize: 18)), automaticallyImplyLeading: false, ), body: ListView( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), children: [ - // Appearance section - _buildSectionHeader(context, 'Appearance'), - SwitchListTile( - secondary: Icon( - prefs.themeMode == 'dark' ? Icons.dark_mode : Icons.light_mode, - ), - title: const Text('Theme'), - subtitle: Text(prefs.themeMode == 'dark' ? 'Dark mode' : 'Light mode'), - value: prefs.themeMode == 'dark', - onChanged: (isDark) { - appState.setThemeMode(isDark ? 'dark' : 'light'); - }, - ), - SwitchListTile( - secondary: Icon( - prefs.isImperial ? Icons.square_foot : Icons.straighten, - ), - title: const Text('Units'), - subtitle: Text(prefs.isImperial ? 'Imperial (mi, ft)' : 'Metric (km, m)'), - value: prefs.isImperial, - onChanged: (isImperial) { - appState.setUnitSystem(isImperial ? 'imperial' : 'metric'); - }, - ), - - const Divider(), - - // Wardriving Settings section - _buildSectionHeader(context, 'Wardriving Settings'), - - // Auto-Ping Interval Selector - ListTile( - leading: const Icon(Icons.timer), - title: const Text('Auto-Ping Interval'), - subtitle: Text(prefs.autoPingIntervalDisplay), - trailing: const Icon(Icons.chevron_right), - enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showIntervalSelector(context, appState), - ), - - // Hybrid Mode Toggle - SwitchListTile( - secondary: const Icon(Icons.compare_arrows), - title: Row( - children: [ - const Flexible(child: Text('Hybrid Mode', overflow: TextOverflow.ellipsis)), - const SizedBox(width: 4), - GestureDetector( - onTap: () => _showHybridModeInfo(context), - child: Icon( - Icons.info_outline, - size: 18, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + // Lock indicator + if (isAutoMode) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + border: + Border.all(color: Colors.amber.withValues(alpha: 0.3)), ), - ], + child: const Row( + children: [ + Icon(Icons.lock, size: 16, color: Colors.amber), + SizedBox(width: 8), + Text( + 'Some settings locked during auto-ping', + style: TextStyle(fontSize: 12, color: Colors.amber), + ), + ], + ), + ), ), - subtitle: appState.enforceHybrid - ? const Text( - 'Set by Regional Admin — hybrid uses 50% fewer flood packets, improving mesh health.', - style: TextStyle(color: Colors.amber), - ) - : const Text('Combines Active and Passive modes'), - value: appState.enforceHybrid ? true : prefs.hybridModeEnabled, - onChanged: (isAutoMode || appState.enforceHybrid) ? null : (value) { - appState.updatePreferences(prefs.copyWith(hybridModeEnabled: value)); - }, - ), - // Discovery Drop Toggle - SwitchListTile( - secondary: const Icon(Icons.signal_wifi_off), - title: Row( - children: [ - const Flexible(child: Text('Discovery Drop', overflow: TextOverflow.ellipsis)), - const SizedBox(width: 4), - GestureDetector( - onTap: () => _showDiscDropInfo(context), - child: Icon( - Icons.info_outline, - size: 18, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + // General + _buildSection(context, 'General', [ + SwitchListTile( + secondary: Icon( + prefs.themeMode == 'dark' ? Icons.dark_mode : Icons.light_mode, + ), + title: const Text('Theme'), + subtitle: + Text(prefs.themeMode == 'dark' ? 'Dark mode' : 'Light mode'), + value: prefs.themeMode == 'dark', + onChanged: (isDark) { + appState.setThemeMode(isDark ? 'dark' : 'light'); + }, + ), + if (!kIsWeb) _BackgroundModeToggle(appState: appState), + SwitchListTile( + secondary: Icon( + prefs.isImperial ? Icons.square_foot : Icons.straighten, + ), + title: const Text('Units'), + subtitle: Text( + prefs.isImperial ? 'Imperial (mi, ft)' : 'Metric (km, m)'), + value: prefs.isImperial, + onChanged: (isImperial) { + appState.setUnitSystem(isImperial ? 'imperial' : 'metric'); + }, + ), + SwitchListTile( + secondary: Icon( + appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), + title: const Text('Sound Notifications'), + subtitle: Text( + appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), + value: appState.isSoundEnabled, + onChanged: (_) => appState.toggleSoundEnabled(), + ), + if (appState.isSoundEnabled) ...[ + SwitchListTile( + secondary: const SizedBox(width: 24), + title: const Text('Ping Sent'), + subtitle: const Text('Sound when TX ping or discovery is sent'), + value: appState.isTxSoundEnabled, + onChanged: (value) => appState.setTxSoundEnabled(value), + ), + SwitchListTile( + secondary: const SizedBox(width: 24), + title: const Text('Response Received'), + subtitle: + const Text('Sound when repeater echo or RX is received'), + value: appState.isRxSoundEnabled, + onChanged: (value) => appState.setRxSoundEnabled(value), + ), + SwitchListTile( + secondary: const SizedBox(width: 24), + title: const Text('Disconnect Alert'), + subtitle: + const Text('Triple beep when pinging stops unexpectedly'), + value: appState.isDisconnectAlertEnabled, + onChanged: (value) => appState.setDisconnectAlertEnabled(value), + ), + ], + ]), + + // Map Management + _buildSection(context, 'Map Management', [ + if (!kIsWeb) + ListTile( + leading: const Icon(Icons.download_for_offline), + title: const Text('Offline Maps'), + subtitle: const Text('Download map areas for offline use'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const OfflineMapsScreen(), + ), + ); + }, + ), + SwitchListTile( + secondary: + Icon(prefs.mapTilesEnabled ? Icons.cloud : Icons.cloud_off), + title: const Text('Use Downloaded Tiles Only'), + subtitle: Text(prefs.mapTilesEnabled + ? 'Online tiles load normally' + : 'Only downloaded areas are shown · no network tile requests'), + value: !prefs.mapTilesEnabled, + onChanged: (value) { + appState + .updatePreferences(prefs.copyWith(mapTilesEnabled: !value)); + }, + ), + if (prefs.mapTilesEnabled) + ListTile( + leading: const Icon(Icons.opacity), + title: const Text('Coverage Overlay Opacity'), + subtitle: Slider( + value: prefs.coverageOverlayOpacity.clamp(0.3, 1.0), + min: 0.3, + max: 1.0, + divisions: 7, + label: '${(prefs.coverageOverlayOpacity * 100).round()}%', + onChanged: (value) => + appState.setCoverageOverlayOpacity(value), ), - ], + trailing: + Text('${(prefs.coverageOverlayOpacity * 100).round()}%'), + ), + if (prefs.mapTilesEnabled) + ListTile( + leading: const Icon(Icons.grid_on), + title: const Text('Grid Mode'), + subtitle: Text(prefs.coverageGridSize == 100 + ? 'Detailed (More detailed cells, non grouped repeaters)' + : 'Simplified (Merged cells, grouped repeaters)'), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showCoverageGridSelector(context, appState), + ), + ListTile( + leading: const Icon(Icons.visibility), + title: const Text('Color Vision'), + subtitle: Text(_colorVisionLabel(prefs.colorVisionType)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showColorVisionSelector(context, appState), ), - subtitle: appState.enforceDiscDrop - ? const Text( - 'Set by Regional Admin — reports dead zones for network analysis.', - style: TextStyle(color: Colors.amber), - ) - : const Text('Count failed discoveries as failed pings'), - value: appState.enforceDiscDrop ? true : prefs.discDropEnabled, - onChanged: (isAutoMode || appState.enforceDiscDrop) ? null : (value) { - appState.updatePreferences(prefs.copyWith(discDropEnabled: value)); - }, - ), - - // CARpeater Filter Setting - SwitchListTile( - secondary: const Icon(Icons.filter_alt), - title: const Text('CARpeater Filter'), - subtitle: Text(prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null - ? 'Pass-through: stripping 0x${prefs.ignoreRepeaterId}' - : 'Tap to set CARpeater repeater ID'), - value: prefs.ignoreCarpeater, - onChanged: isAutoMode ? null : (value) { - if (value && prefs.ignoreRepeaterId == null) { - // Show dialog to set repeater ID when enabling - _showRepeaterIdDialog(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(ignoreCarpeater: value)); - } - }, - ), + ListTile( + leading: const Icon(Icons.place), + title: const Text('Map Marker Style'), + subtitle: Text(_markerStyleLabel(prefs.markerStyle)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showMarkerStyleSelector(context, appState), + ), + ListTile( + leading: const Icon(Icons.my_location), + title: const Text('GPS Marker'), + subtitle: Text(_gpsMarkerLabel(prefs.gpsMarkerStyle)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showGpsMarkerSelector(context, appState), + ), + SwitchListTile( + secondary: const Icon(Icons.cell_tower), + title: const Text('Top Repeaters on Map'), + subtitle: + const Text('Show top 3 repeaters by SNR from last ping'), + value: prefs.showTopRepeaters, + onChanged: (value) { + appState + .updatePreferences(prefs.copyWith(showTopRepeaters: value)); + }, + ), + // Live Activities are iOS-only, so the switch would be inert + // anywhere else. + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) + SwitchListTile( + secondary: const Icon(Icons.badge), + title: const Text('Repeater Names on Live Activity'), + subtitle: Text(prefs.liveActivityShowNames + ? 'Named rows on CarPlay and watch' + : 'Compact grid fits more repeaters'), + value: prefs.liveActivityShowNames, + onChanged: (value) { + appState.updatePreferences( + prefs.copyWith(liveActivityShowNames: value)); + }, + ), + ]), - // CARpeater ID - show when enabled - if (prefs.ignoreCarpeater) + // Ping Settings + _buildSection(context, 'Ping Settings', [ + SwitchListTile( + secondary: const Icon(Icons.visibility_off), + title: const Text('Anonymous Mode'), + subtitle: Text(prefs.anonymousMode + ? 'Device broadcasts as "Anonymous"' + : 'Device uses its real name'), + value: prefs.anonymousMode, + onChanged: isAutoMode + ? null + : (value) { + if (value) { + _showEnableAnonymousConfirmation(context, appState); + } else { + if (appState.connectionStatus == + ConnectionStatus.connected) { + _showDisableAnonymousConfirmation(context, appState); + } else { + appState.setAnonymousMode(false); + } + } + }, + ), + SwitchListTile( + secondary: const Icon(Icons.my_location), + title: const Text('Broadcast My Coordinates'), + subtitle: Text(prefs.broadcastCoords + ? 'Real GPS is sent on the air' + : 'Coordinates stay private (sent only to the server)'), + value: prefs.broadcastCoords, + onChanged: (value) => appState.setBroadcastCoords(value), + ), ListTile( - leading: const SizedBox(width: 24), // Indent - title: const Text('CARpeater ID'), - subtitle: Text(prefs.ignoreRepeaterId != null - ? '0x${prefs.ignoreRepeaterId}' - : 'Not set'), + leading: const Icon(Icons.timer), + title: const Text('Auto-Ping Interval'), + subtitle: Text(prefs.autoPingIntervalDisplay), trailing: const Icon(Icons.chevron_right), enabled: !isAutoMode, - onTap: isAutoMode ? null : () => _showRepeaterIdDialog(context, appState), - ), - - // Anonymous Mode Toggle - SwitchListTile( - secondary: const Icon(Icons.visibility_off), - title: const Text('Anonymous Mode'), - subtitle: Text(prefs.anonymousMode - ? 'Device broadcasts as "Anonymous"' - : 'Device uses its real name'), - value: prefs.anonymousMode, - onChanged: isAutoMode ? null : (value) { - if (value) { - _showEnableAnonymousConfirmation(context, appState); - } else { - if (appState.connectionStatus == ConnectionStatus.connected) { - _showDisableAnonymousConfirmation(context, appState); - } else { - appState.setAnonymousMode(false); - } - } - }, - ), - - // Disable RSSI Filter Toggle - SwitchListTile( - secondary: const Icon(Icons.shield_outlined), - title: const Text('Disable RSSI Filter'), - subtitle: Text(prefs.disableRssiFilter - ? 'Allows all signal strengths' - : 'Drops signals stronger than -30 dBm'), - value: prefs.disableRssiFilter, - onChanged: isAutoMode ? null : (value) { - if (value) { - _showDisableRssiFilterConfirmation(context, appState); - } else { - appState.updatePreferences(prefs.copyWith(disableRssiFilter: false)); - } - }, - ), + onTap: isAutoMode + ? null + : () => _showIntervalSelector(context, appState), + ), + ListTile( + leading: const Icon(Icons.straighten), + title: const Text('Min Ping Distance'), + subtitle: Text(prefs.minPingDistanceDisplay), + trailing: const Icon(Icons.chevron_right), + enabled: !isAutoMode, + onTap: isAutoMode + ? null + : () => _showDistanceSelector(context, appState), + ), + SwitchListTile( + secondary: const Icon(Icons.timer_off), + title: const Text('Auto-Stop After Idle'), + subtitle: + const Text('Stops auto-ping after 30 min without movement'), + value: prefs.autoStopAfterIdle, + onChanged: isAutoMode + ? null + : (value) { + appState.updatePreferences( + prefs.copyWith(autoStopAfterIdle: value)); + }, + ), + ]), - // Lock indicator - if (isAutoMode) - const Padding( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( + // Modes + _buildSection(context, 'Modes', [ + SwitchListTile( + secondary: const Icon(Icons.waves), + title: const Text('Flood Traffic'), + subtitle: appState.floodDisabled + ? const Text( + 'Set by Regional Admin. Flood traffic is turned off here, so ' + 'Active and Hybrid modes are unavailable. Passive and Trace ' + 'still work.', + style: TextStyle(color: Colors.blue), + ) + : const Text( + 'Show Active, Hybrid, and manual Send Ping controls'), + value: appState.floodDisabled ? false : prefs.floodTrafficEnabled, + onChanged: (isAutoMode || appState.floodDisabled) + ? null + : (value) { + appState.updatePreferences( + prefs.copyWith(floodTrafficEnabled: value)); + }, + ), + SwitchListTile( + secondary: const Icon(Icons.compare_arrows), + title: Row( children: [ - Icon(Icons.lock, size: 16, color: Colors.amber), - SizedBox(width: 8), - Expanded( - child: Text( - 'Settings locked during auto-ping mode', - style: TextStyle( - fontSize: 12, - color: Colors.amber, - ), + const Flexible( + child: + Text('Hybrid Mode', overflow: TextOverflow.ellipsis)), + const SizedBox(width: 4), + IconButton( + onPressed: () => _showHybridModeInfo(context), + icon: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), ), ], ), + subtitle: appState.enforceHybrid + ? const Text( + 'Set by Regional Admin — hybrid uses 50% fewer flood packets, improving mesh health.', + style: TextStyle(color: Colors.amber), + ) + : const Text('Combines Active and Passive modes'), + value: appState.enforceHybrid ? true : prefs.hybridModeEnabled, + onChanged: (isAutoMode || appState.enforceHybrid) + ? null + : (value) { + appState.updatePreferences( + prefs.copyWith(hybridModeEnabled: value)); + }, ), - - // Radio Settings section - const Divider(), - _buildSectionHeader(context, 'Radio Settings'), - - // Path Bytes Setting - ListTile( - leading: const Icon(Icons.linear_scale), - title: Row( - children: [ - const Flexible(child: Text('Path Bytes', overflow: TextOverflow.ellipsis)), - const SizedBox(width: 4), - GestureDetector( - onTap: () => _showHopBytesInfo(context), - child: Icon( - Icons.info_outline, - size: 18, - color: Theme.of(context).colorScheme.onSurfaceVariant, + SwitchListTile( + secondary: const Icon(Icons.signal_wifi_off), + title: Row( + children: [ + const Flexible( + child: Text('Discovery Drop', + overflow: TextOverflow.ellipsis)), + const SizedBox(width: 4), + IconButton( + onPressed: () => _showDiscDropInfo(context), + icon: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), ), - ), - ], - ), - subtitle: appState.enforceHopBytes - ? const Text( - 'Set by Regional Admin — larger IDs reduce collisions in your region.', - style: TextStyle(color: Colors.amber), - ) - : (appState.isConnected && !appState.supportsMultiBytePaths) - ? const Text( - 'Firmware 1.14+ required', - style: TextStyle(color: Colors.amber), - ) - : !appState.isConnected - ? const Text( - 'Connect to radio to configure', - style: TextStyle(color: Colors.amber), - ) - : const Text('Repeater ID size in path hops'), - trailing: DropdownButton( - value: appState.enforceHopBytes ? appState.effectiveHopBytes : appState.hopBytes, - underline: const SizedBox(), - items: const [ - DropdownMenuItem(value: 1, child: Text('1')), - DropdownMenuItem(value: 2, child: Text('2')), - DropdownMenuItem(value: 3, child: Text('3')), - ], - onChanged: (!appState.isConnected || isAutoMode || appState.enforceHopBytes || !appState.supportsMultiBytePaths) + ], + ), + subtitle: appState.enforceDiscDrop + ? const Text( + 'Set by Regional Admin — reports dead zones for network analysis.', + style: TextStyle(color: Colors.amber), + ) + : const Text('Count failed discoveries as failed pings'), + value: appState.enforceDiscDrop ? true : prefs.discDropEnabled, + onChanged: (isAutoMode || appState.enforceDiscDrop) ? null : (value) { - if (value != null) appState.setHopBytes(value); + if (value == true) { + _showDiscDropEnableConfirmation(context, appState); + } else { + appState.updatePreferences( + prefs.copyWith(discDropEnabled: false)); + } }, ), - ), + ]), - // Background Mode - for "Always" location permission (iOS and Android) - if (!kIsWeb) ...[ - const Divider(), - _buildSectionHeader(context, 'Background Mode'), - _BackgroundModeToggle(appState: appState), - ], - - const Divider(), - - // Data Management section - _buildSectionHeader(context, 'Data Management'), - ListTile( - leading: const Icon(Icons.cloud_queue), - title: const Text('Queued Pings'), - subtitle: Text('${appState.queueSize} items waiting'), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.cloud_upload), - onPressed: appState.queueSize > 0 - ? () => appState.forceUploadQueue() - : null, - tooltip: 'Force upload', - ), - IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: appState.queueSize > 0 - ? () => _confirmClearQueue(context, appState) - : null, - tooltip: 'Clear queue', - ), - ], + // Filtering + _buildSection(context, 'Filtering', [ + SwitchListTile( + secondary: const Icon(Icons.filter_alt), + title: const Text('CARpeater Filter'), + subtitle: Text( + prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null + ? 'Pass-through: stripping 0x${prefs.ignoreRepeaterId}' + : 'Tap to set CARpeater repeater ID'), + value: prefs.ignoreCarpeater, + onChanged: isAutoMode + ? null + : (value) { + if (value && prefs.ignoreRepeaterId == null) { + _showRepeaterIdDialog(context, appState); + } else { + appState.updatePreferences( + prefs.copyWith(ignoreCarpeater: value)); + } + }, ), - ), - - // Clear Map Markers - ListTile( - leading: const Icon(Icons.delete_sweep), - title: const Text('Clear Map Markers'), - subtitle: const Text('Remove all TX/RX markers from map'), - onTap: () => _confirmClearPings(context, appState), - ), - - // Offline Sessions - if (appState.offlineSessions.isNotEmpty) ...[ - _buildSectionHeader(context, 'Offline Sessions'), - ...appState.offlineSessions.map((session) => _OfflineSessionTile( - session: session, - onUpload: () => _uploadOfflineSession(context, appState, session.filename), - onDelete: () => _confirmDeleteOfflineSession(context, appState, session.filename), - onDownload: () => _downloadOfflineSession(context, appState, session.filename), - )), - ], - - const Divider(), - - // About section - _buildSectionHeader(context, 'About'), - ListTile( - leading: const Icon(Icons.perm_identity), - title: const Text('Device ID'), - subtitle: Text(appState.deviceId), - ), - const ListTile( - leading: Icon(Icons.info_outline), - title: Text(AppConstants.appName), - subtitle: Text('Mesh network coverage mapper'), - ), - ListTile( - leading: const Icon(Icons.new_releases_outlined), - title: const Text('Version'), - subtitle: Text(AppConstants.appVersion), - onTap: () => _onVersionTap(appState), - ), - ListTile( - leading: const Icon(Icons.feedback_outlined), - title: const Text('Submit Feedback'), - subtitle: const Text('Report bugs or request features'), - onTap: () => _showBugReportDialog(context, appState), - ), - ListTile( - leading: const FaIcon(FontAwesomeIcons.github), - title: const Text('GitHub'), - subtitle: const Text('View issues and source code'), - onTap: () => _launchUrl('https://github.com/MeshMapper/MeshMapper_Project'), - ), - ListTile( - leading: const FaIcon(FontAwesomeIcons.discord), - title: const Text('Discord'), - subtitle: const Text('Join our community chat'), - onTap: () => _launchUrl('https://discord.gg/D26P6c6QmG'), - ), - ListTile( - leading: const Icon(Icons.groups), - title: const Text('Community'), - subtitle: const Text('Built with contributions from the Greater Ottawa Mesh Radio Enthusiasts community'), - onTap: () => _launchUrl('https://ottawamesh.ca/'), - ), - ListTile( - leading: const Icon(Icons.coffee), - title: const Text('Buy us a coffee ☕'), - subtitle: const Text('Support MeshMapper development'), - onTap: () => _launchUrl('https://buymeacoffee.com/meshmapper'), - ), - - // Exit Options (Android only) - if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) ...[ - const Divider(), - _buildSectionHeader(context, 'Exit Options'), - - // Auto-close toggle + if (prefs.ignoreCarpeater) + ListTile( + leading: const SizedBox(width: 24), + title: const Text('CARpeater ID'), + subtitle: Text(prefs.ignoreRepeaterId != null + ? '0x${prefs.ignoreRepeaterId}' + : 'Not set'), + trailing: const Icon(Icons.chevron_right), + enabled: !isAutoMode, + onTap: isAutoMode + ? null + : () => _showRepeaterIdDialog(context, appState), + ), SwitchListTile( - secondary: const Icon(Icons.exit_to_app), - title: const Text('Close App After Disconnect'), - subtitle: const Text('Automatically exit the app when disconnecting'), - value: prefs.closeAppAfterDisconnect, - onChanged: (value) => appState.setCloseAppAfterDisconnect(value), + secondary: const Icon(Icons.shield_outlined), + title: const Text('Disable RSSI Filter'), + subtitle: Text(prefs.disableRssiFilter + ? 'Allows all signal strengths' + : 'Drops signals stronger than -30 dBm'), + value: prefs.disableRssiFilter, + onChanged: isAutoMode + ? null + : (value) { + if (value) { + _showDisableRssiFilterConfirmation(context, appState); + } else { + appState.updatePreferences( + prefs.copyWith(disableRssiFilter: false)); + } + }, ), + ]), - // Manual close button + // Radio Settings + _buildSection(context, 'Radio', [ ListTile( - leading: const Icon(Icons.power_settings_new, color: Colors.red), - title: const Text('Close App'), - subtitle: const Text('Exit the app completely'), - onTap: () => _showCloseAppConfirmation(context, appState), + leading: const Icon(Icons.linear_scale), + title: Row( + children: [ + const Flexible( + child: Text('TX Bytes', overflow: TextOverflow.ellipsis)), + const SizedBox(width: 4), + IconButton( + onPressed: () => _showHopBytesInfo(context), + icon: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + subtitle: appState.enforceHopBytes + ? const Text( + 'Set by Regional Admin — larger IDs reduce collisions in your region.', + style: TextStyle(color: Colors.amber), + ) + : (appState.isConnected && !appState.supportsMultiBytePaths) + ? const Text( + 'Firmware 1.14+ required', + style: TextStyle(color: Colors.amber), + ) + : !appState.isConnected + ? const Text( + 'Connect to radio to configure', + style: TextStyle(color: Colors.amber), + ) + : const Text('Repeater ID size in TX/RX path hops'), + trailing: DropdownButton( + value: appState.enforceHopBytes + ? appState.effectiveHopBytes + : appState.hopBytes, + underline: const SizedBox(), + items: const [ + DropdownMenuItem(value: 1, child: Text('1')), + DropdownMenuItem(value: 2, child: Text('2')), + DropdownMenuItem(value: 3, child: Text('3')), + ], + onChanged: (!appState.isConnected || + isAutoMode || + appState.enforceHopBytes || + !appState.supportsMultiBytePaths) + ? null + : (value) { + if (value != null) appState.setHopBytes(value); + }, + ), + ), + ListTile( + leading: const Icon(Icons.gps_fixed), + title: Row( + children: [ + const Flexible( + child: + Text('Trace Bytes', overflow: TextOverflow.ellipsis)), + const SizedBox(width: 4), + IconButton( + onPressed: () => _showTraceBytesInfo(context), + icon: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + subtitle: !appState.isConnected + ? const Text( + 'Connect to radio to configure', + style: TextStyle(color: Colors.amber), + ) + : (appState.isConnected && !appState.supportsMultiBytePaths) + ? const Text( + 'Firmware 1.14+ required', + style: TextStyle(color: Colors.amber), + ) + : const Text('Repeater ID size in trace path'), + trailing: DropdownButton( + value: appState.traceHopBytes, + underline: const SizedBox(), + items: const [ + DropdownMenuItem(value: 1, child: Text('1')), + DropdownMenuItem(value: 2, child: Text('2')), + DropdownMenuItem(value: 4, child: Text('4')), + ], + onChanged: (!appState.isConnected || + isAutoMode || + !appState.supportsMultiBytePaths) + ? null + : (value) { + if (value != null) appState.setTraceHopBytes(value); + }, + ), ), - ], - - // Developer Tools section - only visible when developer mode is enabled - if (appState.developerModeEnabled) ...[ - const Divider(), - - _buildSectionHeader(context, 'Developer Tools'), - - // Developer mode toggle (to disable) SwitchListTile( - secondary: const Icon(Icons.developer_mode), - title: const Text('Developer Mode'), - subtitle: const Text('Disable to hide developer tools'), - value: appState.developerModeEnabled, + secondary: const Icon(Icons.delete_sweep), + title: Row( + children: [ + const Flexible(child: Text('Delete Channel on Disconnect')), + const SizedBox(width: 4), + IconButton( + onPressed: () => _showDeleteChannelInfo(context), + icon: Icon( + Icons.info_outline, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + subtitle: Text(prefs.deleteChannelOnDisconnect + ? 'Removes #wardriving channel from device' + : 'Keeps #wardriving channel on device'), + value: prefs.deleteChannelOnDisconnect, onChanged: (value) { - appState.setDeveloperMode(value); + appState.updatePreferences( + prefs.copyWith(deleteChannelOnDisconnect: value)); }, ), + ]), - // GPS Simulator Toggle - SwitchListTile( - secondary: Icon( - Icons.gps_fixed, - color: appState.isGpsSimulatorEnabled ? Colors.orange : null, - ), - title: Row( - children: [ - const Text('GPS Simulator'), - if (appState.isGpsSimulatorEnabled) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.orange, - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'SIMULATED', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), + // Data Management + _buildSection(context, 'Data', [ + ListTile( + leading: const Icon(Icons.cloud_queue), + title: const Text('Queued Pings'), + subtitle: Text('${appState.queueSize} items waiting'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.cloud_upload), + onPressed: appState.queueSize > 0 + ? () => appState.forceUploadQueue() + : null, + tooltip: 'Force upload', + ), + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: appState.queueSize > 0 + ? () => _confirmClearQueue(context, appState) + : null, + tooltip: 'Clear queue', ), ], - ], + ), ), - subtitle: Text(appState.isGpsSimulatorEnabled - ? 'Smooth simulated movement active' - : 'Use simulated GPS for testing'), - value: appState.isGpsSimulatorEnabled, - onChanged: (value) { - if (value) { - appState.enableGpsSimulator(); - } else { - appState.disableGpsSimulator(); - } - }, - ), + ListTile( + leading: const Icon(Icons.delete_sweep), + title: const Text('Clear Map Markers'), + subtitle: const Text('Remove all TX/RX markers from map'), + onTap: () => _confirmClearPings(context, appState), + ), + ]), - // Simulator Settings (only when enabled) - if (appState.isGpsSimulatorEnabled) ...[ - // Speed Slider + // Offline Sessions + _buildSection(context, 'Offline Sessions', [ + if (appState.offlineSessions.isEmpty) + ListTile( + leading: Icon(Icons.cloud_off, color: Colors.grey.shade400), + title: Text( + 'No offline sessions stored', + style: TextStyle(color: Colors.grey.shade500), + ), + subtitle: Text( + 'Sessions recorded in offline mode will appear here', + style: TextStyle(fontSize: 12, color: Colors.grey.shade400), + ), + ) + else + ...appState.offlineSessions.map((session) => _OfflineSessionTile( + session: session, + uploadEnabled: !appState.isUploadingOfflineSession, + onUpload: () => _uploadOfflineSession( + context, appState, session.filename), + onDelete: () => _confirmDeleteOfflineSession( + context, appState, session.filename), + onDownload: () => _downloadOfflineSession( + context, appState, session.filename), + )), + ]), + + // MeshMapper Account — mobile only. The sign-in flow needs an + // OS-registered URL scheme, which the web build cannot have. + if (!kIsWeb) + _buildSection(context, 'MeshMapper Account', [ + if (!appState.isPortalLoggedIn) + ListTile( + leading: const Icon(Icons.account_circle_outlined), + title: const Text('Sign in to MyMeshMapper'), + subtitle: const Text( + 'Link your radios so wardriving counts toward your account'), + trailing: const Icon(Icons.open_in_new, size: 18), + onTap: () => _startPortalSignIn(context, appState), + ) + else ...[ + ListTile( + leading: + const Icon(Icons.account_circle, color: Colors.green), + title: + Text(appState.portalAccount?.displayName ?? 'Signed in'), + subtitle: Text('@${appState.portalAccount?.username ?? ''}'), + ), + ListTile( + leading: Icon( + appState.isCurrentDeviceLinked + ? Icons.link + : Icons.link_off, + color: appState.isCurrentDeviceLinked + ? Colors.green + : Colors.grey, + ), + title: const Text('This Device'), + subtitle: Text( + !appState.isConnected + ? 'Connect a device to link it' + : appState.isCurrentDeviceLinked + ? 'Linked to your account' + : 'Not linked', + ), + trailing: !appState.isConnected + ? null + : appState.isCurrentDeviceLinked + ? TextButton( + onPressed: isAutoMode + ? null + : () => + _unlinkPortalDevice(context, appState), + child: const Text('Unlink'), + ) + : TextButton( + // Linking drives the radio (CMD_SIGN), so it + // follows this screen's auto-ping lock. + onPressed: isAutoMode + ? null + : () => _linkPortalDevice(context, appState), + child: const Text('Link now'), + ), + ), + ListTile( + leading: const Icon(Icons.devices_other), + title: const Text('Linked Devices'), + subtitle: Text( + '${appState.portalLinkedDeviceCount} device(s) on this account'), + trailing: const Icon(Icons.refresh), + onTap: () => _refreshPortalAccount(context, appState), + ), + if (appState.hasPortalLinkResets) + ListTile( + leading: const Icon(Icons.replay), + title: const Text('Re-enable Link Prompts'), + subtitle: + const Text('Ask again for declined or unsupported ' + 'devices'), + onTap: () => _resetPortalDeclines(context, appState), + ), + ListTile( + leading: const Icon(Icons.logout, color: Colors.orange), + title: const Text('Sign Out'), + subtitle: + const Text('Keeps your linked devices on the server'), + onTap: () => + _showPortalSignOutConfirmation(context, appState), + ), + ], + ]), + + // API Endpoints + _buildSection(context, 'API Endpoints', [ + const ListTile( + leading: Icon(Icons.cloud_done, color: Colors.green), + title: Text('MeshMapper API'), + subtitle: Text('Always active'), + ), + SwitchListTile( + secondary: const Icon(Icons.api), + title: const Text('Custom API Endpoint'), + subtitle: Text(prefs.customApiEnabled + ? (prefs.customApiUrl ?? 'Not configured') + : 'Forward pings to a third-party server'), + value: prefs.customApiEnabled, + onChanged: isAutoMode + ? null + : (value) { + if (value) { + _showCustomApiDisclaimer(context, appState); + } else { + appState.updatePreferences( + prefs.copyWith(customApiEnabled: false)); + } + }, + ), + if (prefs.customApiEnabled) ...[ + ListTile( + leading: const SizedBox(width: 24), + title: const Text('Endpoint URL'), + subtitle: Text(prefs.customApiUrl ?? 'Not set'), + trailing: const Icon(Icons.chevron_right), + onTap: isAutoMode + ? null + : () => _showCustomApiUrlDialog(context, appState), + ), + ListTile( + leading: const SizedBox(width: 24), + title: const Text('API Key'), + subtitle: Text(prefs.customApiKey != null + ? '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' + : 'Not set'), + trailing: const Icon(Icons.chevron_right), + onTap: isAutoMode + ? null + : () => _showCustomApiKeyDialog(context, appState), + ), + SwitchListTile( + secondary: const SizedBox(width: 24), + title: const Text('Include Contact Key'), + subtitle: + const Text('Share device public key prefix with endpoint'), + value: prefs.customApiIncludeContact, + onChanged: isAutoMode + ? null + : (value) { + appState.updatePreferences( + prefs.copyWith(customApiIncludeContact: value)); + }, + ), + ListTile( + leading: const Icon(Icons.content_paste), + title: const Text('Import from Clipboard'), + subtitle: const Text('Paste a meshmapper:// config link'), + onTap: isAutoMode + ? null + : () => _importCustomApiFromClipboard(context, appState), + ), + ], + ]), + + // About + _buildSection(context, 'About', [ + const ListTile( + leading: Icon(Icons.info_outline), + title: Text(AppConstants.appName), + subtitle: Text('Mesh network coverage mapper'), + ), + ListTile( + leading: const Icon(Icons.new_releases_outlined), + title: const Text('Version'), + subtitle: Text(AppConstants.appVersion), + onTap: () => _onVersionTap(appState), + ), ListTile( - leading: const SizedBox(width: 24), - title: const Text('Simulation Speed'), - subtitle: Slider( - value: appState.gpsSimulatorSpeed, - min: 10, - max: 120, - divisions: 11, - label: formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), + leading: const Icon(Icons.feedback_outlined), + title: const Text('Submit Feedback'), + subtitle: const Text('Report bugs or request features'), + onTap: () => _showBugReportDialog(context, appState), + ), + ListTile( + leading: const FaIcon(FontAwesomeIcons.github), + title: const Text('GitHub'), + subtitle: const Text('View issues and source code'), + onTap: () => _launchUrl( + 'https://github.com/MeshMapper/MeshMapper_Project'), + ), + ListTile( + leading: const FaIcon(FontAwesomeIcons.discord), + title: const Text('Discord'), + subtitle: const Text('Join our community chat'), + onTap: () => _launchUrl('https://discord.gg/D26P6c6QmG'), + ), + ListTile( + leading: const Icon(Icons.groups), + title: const Text('Community'), + subtitle: const Text( + 'Built with contributions from the Greater Ottawa Mesh Radio Enthusiasts community'), + onTap: () => _launchUrl('https://ottawamesh.ca/'), + ), + // Buy Me a Coffee — external donation links are fine on Android/Web + // but violate Apple guideline 3.1.1 on iOS, so omit it on iOS. + if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS) + ListTile( + leading: const Icon(Icons.coffee), + title: const Text('Buy us a coffee'), + subtitle: const Text('Support MeshMapper development'), + onTap: () => _launchUrl('https://buymeacoffee.com/meshmapper'), + ), + ]), + + // Exit Options (Android only) + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) + _buildSection(context, 'Exit', [ + SwitchListTile( + secondary: const Icon(Icons.exit_to_app), + title: const Text('Close App After Disconnect'), + subtitle: + const Text('Automatically exit the app when disconnecting'), + value: prefs.closeAppAfterDisconnect, + onChanged: (value) => + appState.setCloseAppAfterDisconnect(value), + ), + ListTile( + leading: + const Icon(Icons.power_settings_new, color: Colors.red), + title: const Text('Close App'), + subtitle: const Text('Exit the app completely'), + onTap: () => _showCloseAppConfirmation(context, appState), + ), + ]), + + // Developer Tools - only visible when developer mode is enabled + if (appState.developerModeEnabled) + _buildSection(context, 'Developer Tools', [ + SwitchListTile( + secondary: const Icon(Icons.developer_mode), + title: const Text('Developer Mode'), + subtitle: const Text('Disable to hide developer tools'), + value: appState.developerModeEnabled, onChanged: (value) { - appState.setGpsSimulatorSpeed(value); + appState.setDeveloperMode(value); }, ), - trailing: Text( - formatSpeed(appState.gpsSimulatorSpeed, isImperial: prefs.isImperial), - style: const TextStyle(fontWeight: FontWeight.bold), + SwitchListTile( + secondary: Icon( + Icons.gps_fixed, + color: appState.isGpsSimulatorEnabled ? Colors.orange : null, + ), + title: Row( + children: [ + const Text('GPS Simulator'), + if (appState.isGpsSimulatorEnabled) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'SIMULATED', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ], + ], + ), + subtitle: Text(appState.isGpsSimulatorEnabled + ? 'Smooth simulated movement active' + : 'Use simulated GPS for testing'), + value: appState.isGpsSimulatorEnabled, + onChanged: (value) { + if (value) { + appState.enableGpsSimulator(); + } else { + appState.disableGpsSimulator(); + } + }, ), - ), - - // Pattern Selector - ListTile( - leading: const SizedBox(width: 24), - title: const Text('Movement Pattern'), - trailing: SizedBox( - width: 180, - child: DropdownButton( - value: appState.gpsSimulatorPattern, - underline: const SizedBox(), - isExpanded: true, - items: [ - const DropdownMenuItem( - value: SimulatorPattern.straight, - child: Text('Straight Line', overflow: TextOverflow.ellipsis), - ), - const DropdownMenuItem( - value: SimulatorPattern.circle, - child: Text('Circle', overflow: TextOverflow.ellipsis), - ), - const DropdownMenuItem( - value: SimulatorPattern.randomWalk, - child: Text('Random Walk', overflow: TextOverflow.ellipsis), + if (appState.isGpsSimulatorEnabled) ...[ + ListTile( + leading: const SizedBox(width: 24), + title: const Text('Simulation Speed'), + subtitle: Slider( + value: appState.gpsSimulatorSpeed, + min: 10, + max: 120, + divisions: 11, + label: formatSpeed(appState.gpsSimulatorSpeed, + isImperial: prefs.isImperial), + onChanged: (value) { + appState.setGpsSimulatorSpeed(value); + }, + ), + trailing: Text( + formatSpeed(appState.gpsSimulatorSpeed, + isImperial: prefs.isImperial), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ListTile( + leading: const SizedBox(width: 24), + title: const Text('Movement Pattern'), + trailing: SizedBox( + width: 180, + child: DropdownButton( + value: appState.gpsSimulatorPattern, + underline: const SizedBox(), + isExpanded: true, + items: [ + const DropdownMenuItem( + value: SimulatorPattern.straight, + child: Text('Straight Line', + overflow: TextOverflow.ellipsis), + ), + const DropdownMenuItem( + value: SimulatorPattern.circle, + child: + Text('Circle', overflow: TextOverflow.ellipsis), + ), + const DropdownMenuItem( + value: SimulatorPattern.randomWalk, + child: Text('Random Walk', + overflow: TextOverflow.ellipsis), + ), + if (appState.hasSimulatorRoute) + DropdownMenuItem( + value: SimulatorPattern.route, + child: Text( + 'Route: ${appState.simulatorRouteName ?? "Loaded"}', + overflow: TextOverflow.ellipsis, + ), + ), + ], + onChanged: (pattern) { + if (pattern != null) { + appState.setGpsSimulatorPattern(pattern); + } + }, ), - if (appState.hasSimulatorRoute) - DropdownMenuItem( - value: SimulatorPattern.route, - child: Text( - 'Route: ${appState.simulatorRouteName ?? "Loaded"}', - overflow: TextOverflow.ellipsis, + ), + ), + ListTile( + leading: const SizedBox(width: 24), + title: const Text('Load Route File'), + subtitle: Text(appState.hasSimulatorRoute + ? '${appState.simulatorRouteName} (${appState.simulatorRoutePointCount} points)' + : 'KML or GPX file'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.folder_open), + onPressed: () => _pickRouteFile(context, appState), + tooltip: 'Load route file', + ), + if (appState.hasSimulatorRoute) + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + appState.clearSimulatorRoute(); + AppToast.info(context, 'Route cleared'); + }, + tooltip: 'Clear route', + ), + ], + ), + ), + ListTile( + leading: const SizedBox(width: 24), + title: const Text('Reset Position'), + subtitle: Text(appState.hasSimulatorRoute + ? 'Reset to route start' + : 'Reset to Ottawa downtown'), + trailing: IconButton( + icon: const Icon(Icons.restart_alt), + onPressed: () { + appState.resetGpsSimulator(); + AppToast.info( + context, + appState.hasSimulatorRoute + ? 'Reset to route start' + : 'GPS simulator reset to Ottawa downtown', + ); + }, + ), + ), + ], + ]), + + // Debug section (always visible on mobile) + if (!kIsWeb) + _buildSection(context, 'Debug', [ + SwitchListTile( + secondary: Icon( + Icons.bug_report, + color: appState.debugLogsEnabled ? Colors.orange : null, + ), + title: Row( + children: [ + const Text('Debug Logs'), + if (appState.debugLogsEnabled) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'LOGGING', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: Colors.white, + ), ), ), + ], ], - onChanged: (pattern) { - if (pattern != null) { - appState.setGpsSimulatorPattern(pattern); + ), + subtitle: Text( + appState.debugLogsEnabled + ? 'Writing logs to file' + : 'Enable to save debug logs to device', + ), + value: appState.debugLogsEnabled, + onChanged: (value) async { + if (value) { + await appState.enableDebugLogs(); + } else { + await appState.disableDebugLogs(); + } + }, + ), + if (appState.debugLogsEnabled || + appState.debugLogFiles.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Row( + children: [ + Text( + 'Log Files', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Colors.grey.shade600, + ), + ), + const Spacer(), + if (appState.debugLogFiles.isNotEmpty) ...[ + TextButton.icon( + icon: const Icon(Icons.cloud_upload, size: 18), + label: const Text('Upload'), + onPressed: () => + _showUploadLogsDialog(context, appState), + ), + TextButton.icon( + icon: const Icon(Icons.delete_sweep, size: 18), + label: const Text('Delete All'), + onPressed: () => + _confirmDeleteAllLogs(context, appState), + ), + ], + ], + ), + ), + if (appState.debugLogFiles.isEmpty) + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'No debug logs yet', + style: + TextStyle(color: Colors.grey.shade500, fontSize: 13), + ), + ) + else + ...appState.debugLogFiles.asMap().entries.map((entry) { + final index = entry.key; + final file = entry.value; + final filename = file.path.split('/').last; + final sizeBytes = file.lengthSync(); + final isCurrentLog = index == 0; + final timestampMatch = + RegExp(r'meshmapper-debug-(\d+)\.txt') + .firstMatch(filename); + final fileDate = timestampMatch != null + ? DateTime.fromMillisecondsSinceEpoch( + int.parse(timestampMatch.group(1)!) * 1000) + : null; + final dateStr = fileDate != null + ? DateFormat('MMM d, h:mm a').format(fileDate) + : filename; + + String sizeDisplay; + final partCount = + DebugFileLogger.estimatePartCount(sizeBytes); + if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { + final sizeMb = + (sizeBytes / 1024 / 1024).toStringAsFixed(1); + sizeDisplay = '$sizeMb MB ($partCount parts)'; + } else { + sizeDisplay = + '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; + } + if (isCurrentLog) { + sizeDisplay = '$sizeDisplay (current)'; } - }, + + return ListTile( + leading: const Icon(Icons.description, size: 20), + title: + Text(dateStr, style: const TextStyle(fontSize: 13)), + subtitle: Text( + sizeDisplay, + style: const TextStyle(fontSize: 11), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.visibility, size: 20), + onPressed: () => + _showLogViewer(context, appState, file), + tooltip: 'View', + ), + IconButton( + icon: const Icon(Icons.share, size: 20), + onPressed: () => appState.shareDebugLog(file), + tooltip: 'Share', + ), + ], + ), + ); + }), + ], + ]), + + // Pairing history is one-way: after an unpair this remains at the + // bottom of Settings because that failure is when it is most useful. + if (appState.shouldShowWatchDiagnostics) + _buildSection(context, 'Apple Watch', [ + ListTile( + leading: const Icon(Icons.watch_outlined), + title: const Text('Watch Connectivity'), + subtitle: const Text('Inspect pairing and delivery state'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + WatchDiagnosticsScreen(bridge: appState.watchBridge), + ), ), ), + ]), + ], + ), + ); + } + + String _markerStyleLabel(String style) { + switch (style) { + case 'circle': + return 'Outlined Dot'; + case 'pin': + return 'Pin'; + case 'diamond': + return 'Diamond'; + case 'dot': + default: + return 'Dot'; + } + } + + String _gpsMarkerLabel(String style) { + switch (style) { + case 'car': + return 'Car'; + case 'bike': + return 'Bike'; + case 'boat': + return 'Boat'; + case 'walk': + return 'Walk'; + case 'dog': + return 'Dog'; + case 'chomper': + return 'Chomper'; + case 'arrow': + default: + return 'Arrow'; + } + } + + void _showMarkerStyleSelector( + BuildContext context, AppStateProvider appState) { + final options = [ + ('dot', 'Dot', Icons.circle), + ('circle', 'Outlined Dot', Icons.circle_outlined), + ('pin', 'Pin', Icons.place), + ('diamond', 'Diamond', Icons.diamond), + ]; + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('Map Marker Style', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), ), - - // Load Route File - ListTile( - leading: const SizedBox(width: 24), - title: const Text('Load Route File'), - subtitle: Text(appState.hasSimulatorRoute - ? '${appState.simulatorRouteName} (${appState.simulatorRoutePointCount} points)' - : 'KML or GPX file'), - trailing: Row( + RadioGroup( + groupValue: appState.preferences.markerStyle, + onChanged: (v) { + if (v != null) { + appState.updatePreferences( + appState.preferences.copyWith(markerStyle: v)); + } + Navigator.pop(context); + }, + child: Column( mainAxisSize: MainAxisSize.min, children: [ - IconButton( - icon: const Icon(Icons.folder_open), - onPressed: () => _pickRouteFile(context, appState), - tooltip: 'Load route file', - ), - if (appState.hasSimulatorRoute) - IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - appState.clearSimulatorRoute(); - AppToast.info(context, 'Route cleared'); - }, - tooltip: 'Clear route', + for (final (value, label, icon) in options) + RadioListTile( + secondary: Icon(icon), + title: Text(label), + value: value, ), ], ), ), + const SizedBox(height: 8), + ], + ), + ), + ); + } - // Reset Position Button - ListTile( - leading: const SizedBox(width: 24), - title: const Text('Reset Position'), - subtitle: Text(appState.hasSimulatorRoute - ? 'Reset to route start' - : 'Reset to Ottawa downtown'), - trailing: IconButton( - icon: const Icon(Icons.restart_alt), - onPressed: () { - appState.resetGpsSimulator(); - AppToast.info( - context, - appState.hasSimulatorRoute - ? 'Reset to route start' - : 'GPS simulator reset to Ottawa downtown', - ); - }, + void _showGpsMarkerSelector(BuildContext context, AppStateProvider appState) { + final options = <(String, String, Widget)>[ + ('arrow', 'Arrow', const Icon(Icons.navigation)), + ('car', 'Car', const Icon(Icons.directions_car)), + ('bike', 'Bike', const Icon(Icons.directions_bike)), + ('boat', 'Boat', const Icon(Icons.directions_boat)), + ('walk', 'Walk', const Icon(Icons.directions_walk)), + ('dog', 'Dog', const FaIcon(FontAwesomeIcons.dog)), + ('chomper', 'Chomper', const _ChomperIcon()), + ]; + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('GPS Marker', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + RadioGroup( + groupValue: appState.preferences.gpsMarkerStyle, + onChanged: (v) { + if (v != null) { + appState.updatePreferences( + appState.preferences.copyWith(gpsMarkerStyle: v)); + } + Navigator.pop(context); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final (value, label, icon) in options) + RadioListTile( + secondary: icon, + title: Text(label), + value: value, + ), + ], ), ), + const SizedBox(height: 8), ], - ], // Close developerModeEnabled conditional - - // Debug section (always visible on mobile) - if (!kIsWeb) ...[ - const Divider(), + ), + ), + ); + } - _buildSectionHeader(context, 'Debug'), + String _colorVisionLabel(String type) { + return switch (type) { + 'protanopia' => 'Protanopia (red-blind)', + 'deuteranopia' => 'Deuteranopia (green-blind)', + 'tritanopia' => 'Tritanopia (blue-blind)', + 'achromatopsia' => 'Achromatopsia (monochrome)', + _ => 'Default', + }; + } - SwitchListTile( - secondary: Icon( - Icons.bug_report, - color: appState.debugLogsEnabled ? Colors.orange : null, - ), - title: Row( + /// Grid Mode preset selector — mirrors the web UI's Grid Mode + /// (Simplified = 300 m, Detailed = 100 m + blob, applied server-side). + void _showCoverageGridSelector( + BuildContext context, AppStateProvider appState) { + final options = [ + (300, 'Simplified', 'Merged cells, grouped repeaters'), + (100, 'Detailed', 'More detailed cells, non grouped repeaters'), + ]; + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('Grid Mode', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + RadioGroup( + groupValue: appState.preferences.coverageGridSize, + onChanged: (v) { + if (v != null) { + appState.updatePreferences( + appState.preferences.copyWith(coverageGridSize: v)); + } + Navigator.pop(context); + }, + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - const Text('Debug Logs'), - if (appState.debugLogsEnabled) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.orange, - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'LOGGING', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), + for (final (value, label, subtitle) in options) + RadioListTile( + secondary: const Icon(Icons.grid_on), + title: Text(label), + subtitle: + Text(subtitle, style: const TextStyle(fontSize: 12)), + value: value, ), - ], ], ), - subtitle: Text( - appState.debugLogsEnabled - ? 'Writing logs to file' - : 'Enable to save debug logs to device', - ), - value: appState.debugLogsEnabled, - onChanged: (value) async { - if (value) { - await appState.enableDebugLogs(); - } else { - await appState.disableDebugLogs(); - } - }, ), + const SizedBox(height: 8), + ], + ), + ), + ); + } - // Log Files List (show when toggle is ON or when files exist) - if (appState.debugLogsEnabled || appState.debugLogFiles.isNotEmpty) ...[ - // Section header with Delete All button - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Row( - children: [ - Text( - 'Debug Log Files', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Colors.grey.shade600, - ), + void _showColorVisionSelector( + BuildContext context, AppStateProvider appState) { + final options = [ + ('none', 'Default', 'Standard color palette'), + ( + 'protanopia', + 'Protanopia', + 'Red-blind — difficulty distinguishing red and green' + ), + ( + 'deuteranopia', + 'Deuteranopia', + 'Green-blind — difficulty distinguishing red and green' + ), + ( + 'tritanopia', + 'Tritanopia', + 'Blue-blind — difficulty distinguishing blue and yellow' + ), + ( + 'achromatopsia', + 'Achromatopsia', + 'Total color blindness — sees in greyscale' + ), + ]; + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('Color Vision', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + RadioGroup( + groupValue: appState.preferences.colorVisionType, + onChanged: (v) { + if (v != null) appState.setColorVisionType(v); + Navigator.pop(context); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final (value, label, subtitle) in options) + RadioListTile( + secondary: const Icon(Icons.visibility), + title: Text(label), + subtitle: + Text(subtitle, style: const TextStyle(fontSize: 12)), + value: value, ), - const Spacer(), - if (appState.debugLogFiles.isNotEmpty) ...[ - TextButton.icon( - icon: const Icon(Icons.cloud_upload, size: 18), - label: const Text('Upload'), - onPressed: () => _showUploadLogsDialog(context, appState), - ), - TextButton.icon( - icon: const Icon(Icons.delete_sweep, size: 18), - label: const Text('Delete All'), - onPressed: () => _confirmDeleteAllLogs(context, appState), - ), - ], - ], - ), + ], ), - - // Log files list or empty message - if (appState.debugLogFiles.isEmpty) - Padding( - padding: const EdgeInsets.all(16), - child: Text( - 'No debug logs yet', - style: TextStyle(color: Colors.grey.shade500, fontSize: 13), - ), - ) - else - ...appState.debugLogFiles.asMap().entries.map((entry) { - final index = entry.key; - final file = entry.value; - final filename = file.path.split('/').last; - final sizeBytes = file.lengthSync(); - final isCurrentLog = index == 0; - final timestampMatch = RegExp(r'meshmapper-debug-(\d+)\.txt').firstMatch(filename); - final fileDate = timestampMatch != null - ? DateTime.fromMillisecondsSinceEpoch(int.parse(timestampMatch.group(1)!) * 1000) - : null; - final dateStr = fileDate != null ? DateFormat('MMM d, h:mm a').format(fileDate) : filename; - - String sizeDisplay; - final partCount = DebugFileLogger.estimatePartCount(sizeBytes); - if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { - final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); - sizeDisplay = '$sizeMb MB ($partCount parts)'; - } else { - sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; - } - if (isCurrentLog) { - sizeDisplay = '$sizeDisplay (current)'; - } - - return ListTile( - leading: const Icon(Icons.description, size: 20), - title: Text(dateStr, style: const TextStyle(fontSize: 13)), - subtitle: Text( - sizeDisplay, - style: const TextStyle(fontSize: 11), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.visibility, size: 20), - onPressed: () => _showLogViewer(context, appState, file), - tooltip: 'View', - ), - IconButton( - icon: const Icon(Icons.share, size: 20), - onPressed: () => appState.shareDebugLog(file), - tooltip: 'Share', - ), - ], - ), - ); - }), - ], + ), + const SizedBox(height: 8), ], - - const SizedBox(height: 32), - ], + ), ), ); } - Widget _buildSectionHeader(BuildContext context, String title) { + Widget _buildSection( + BuildContext context, String title, List children) { return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, + padding: const EdgeInsets.only(bottom: 8), + child: Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), ), + ...children, + const SizedBox(height: 4), + ], + ), ), ); } @@ -818,7 +1590,8 @@ class _SettingsScreenState extends State { } } - Future _showBugReportDialog(BuildContext context, AppStateProvider appState) async { + Future _showBugReportDialog( + BuildContext context, AppStateProvider appState) async { final result = await showBugReportDialog(context, appState); if (!context.mounted || result == null) return; @@ -838,7 +1611,8 @@ class _SettingsScreenState extends State { message, duration: const Duration(seconds: 5), actionLabel: result.issueUrl != null ? 'View' : null, - onAction: result.issueUrl != null ? () => _launchUrl(result.issueUrl!) : null, + onAction: + result.issueUrl != null ? () => _launchUrl(result.issueUrl!) : null, ); } else if (result.errorMessage != null) { AppToast.error( @@ -880,7 +1654,7 @@ class _SettingsScreenState extends State { builder: (context) => AlertDialog( title: const Text('Clear Map Markers?'), content: const Text( - 'This will remove all TX/RX markers from the map. This won\'t affect uploaded data.', + 'This will remove all markers from the map. This won\'t affect uploaded data.', ), actions: [ TextButton( @@ -899,7 +1673,8 @@ class _SettingsScreenState extends State { ); } - void _showDisableRssiFilterConfirmation(BuildContext context, AppStateProvider appState) { + void _showDisableRssiFilterConfirmation( + BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( @@ -932,7 +1707,8 @@ class _SettingsScreenState extends State { ); } - void _showEnableAnonymousConfirmation(BuildContext context, AppStateProvider appState) { + void _showEnableAnonymousConfirmation( + BuildContext context, AppStateProvider appState) { final isConnected = appState.connectionStatus == ConnectionStatus.connected; showDialog( context: context, @@ -940,9 +1716,11 @@ class _SettingsScreenState extends State { title: const Text('Enable Anonymous Mode?'), content: Text( 'Your device will be renamed to "Anonymous" for all mesh pings. ' - 'Other mesh users will not see your companion name.\n\n' - 'Your public key is still used to authenticate your session, but ' - 'neither your sessions nor your pings are linked to it on the server.\n\n' + 'Other mesh users will not see your companion name, and you will ' + 'not appear on the public leaderboard.\n\n' + 'Your public key is still sent to authenticate your session, and ' + 'your sessions and pings are still recorded against it. Anonymous ' + 'Mode hides your name. It does not hide your device.\n\n' '${isConnected ? 'Your device will disconnect and reconnect automatically.\n\n' : ''}' 'If the app crashes or BLE disconnects unexpectedly, your device ' 'may remain named "Anonymous" until you reconnect and properly disconnect. ' @@ -966,7 +1744,8 @@ class _SettingsScreenState extends State { ); } - void _showDisableAnonymousConfirmation(BuildContext context, AppStateProvider appState) { + void _showDisableAnonymousConfirmation( + BuildContext context, AppStateProvider appState) { showDialog( context: context, builder: (context) => AlertDialog( @@ -1011,21 +1790,24 @@ class _SettingsScreenState extends State { style: TextStyle(fontSize: 14), ), SizedBox(height: 12), - Text('How it works:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + Text('How it works:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), SizedBox(height: 4), Text( 'Discovery \u2192 wait \u2192 TX Ping \u2192 wait \u2192 Discovery \u2192 ...', style: TextStyle(fontSize: 13, fontFamily: 'monospace'), ), SizedBox(height: 12), - Text('Interval timing:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + Text('Interval timing:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), SizedBox(height: 4), Text( 'At 15s interval, each ping type fires every 30s. Discovery\'s 30s firmware rate limit is naturally respected.', style: TextStyle(fontSize: 13), ), SizedBox(height: 12), - Text('When enabled:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + Text('When enabled:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), SizedBox(height: 4), Text( '\u2022 Replaces the Active button with Hybrid\n' @@ -1082,6 +1864,91 @@ class _SettingsScreenState extends State { ); } + void _showDiscDropEnableConfirmation( + BuildContext context, AppStateProvider appState) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.signal_wifi_off, size: 24), + SizedBox(width: 8), + Text('Discovery Drop'), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'When enabled, failed discovery requests (no repeater responded) are reported to the API as failed pings, helping identify dead zones in the mesh network.', + style: TextStyle(fontSize: 14), + ), + SizedBox(height: 12), + Text( + 'Discovery requests require Repeater firmware 1.10+. If the majority of the mesh is not on this version, it may produce false "no coverage" areas/failed pings.', + style: TextStyle(fontSize: 13, color: Colors.amber), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + appState.updatePreferences( + appState.preferences.copyWith(discDropEnabled: true), + ); + }, + child: const Text('Enable'), + ), + ], + ), + ); + } + + void _showDeleteChannelInfo(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.delete_sweep, size: 24), + SizedBox(width: 8), + Flexible(child: Text('Delete Channel on Disconnect')), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'When enabled, the #wardriving channel is removed from your radio when you disconnect. ' + 'This keeps your radio\'s channel list clean.\n\n' + 'When disabled, the channel remains on the radio after disconnect.', + style: TextStyle(fontSize: 14), + ), + SizedBox(height: 12), + Text( + 'If the app crashes or BLE disconnects unexpectedly, your device ' + 'may retain the #wardriving channel until you reconnect and properly disconnect.', + style: TextStyle(fontSize: 13, color: Colors.amber), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Got it'), + ), + ], + ), + ); + } + void _showHopBytesInfo(BuildContext context) { showDialog( context: context, @@ -1090,7 +1957,7 @@ class _SettingsScreenState extends State { children: [ Icon(Icons.linear_scale, size: 24), SizedBox(width: 8), - Text('Path Bytes'), + Text('TX Bytes'), ], ), content: const Column( @@ -1098,7 +1965,7 @@ class _SettingsScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Controls how many bytes are used to identify each repeater in the packet path. ' + 'Controls how many bytes are used to identify each repeater in TX/RX packet paths. ' 'More bytes = more unique IDs, reducing collisions in large networks.', style: TextStyle(fontSize: 14), ), @@ -1127,6 +1994,56 @@ class _SettingsScreenState extends State { ); } + void _showTraceBytesInfo(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.gps_fixed, size: 24), + SizedBox(width: 8), + Text('Trace Bytes'), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Controls how many bytes are used for the repeater ID in trace path requests. ' + 'This is separate from TX Bytes because traces use a different encoding.', + style: TextStyle(fontSize: 14), + ), + SizedBox(height: 12), + Text( + 'TX/RX uses a simple counter:\n' + '\u2022 Mode 0 \u2192 1 byte\n' + '\u2022 Mode 1 \u2192 2 bytes\n' + '\u2022 Mode 2 \u2192 3 bytes\n\n' + 'Trace uses bitshift encoding:\n' + '\u2022 Mode 0 \u2192 1 byte\n' + '\u2022 Mode 1 \u2192 2 bytes\n' + '\u2022 Mode 2 \u2192 4 bytes', + style: TextStyle(fontSize: 13), + ), + SizedBox(height: 12), + Text( + '3-byte traces are not supported by the MeshCore protocol. ' + 'When your region uses 3-byte TX paths, set Trace Bytes to 4.', + style: TextStyle(fontSize: 13, color: Colors.amber), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Got it'), + ), + ], + ), + ); + } + void _showIntervalSelector(BuildContext context, AppStateProvider appState) { final minInterval = appState.minModeInterval; var currentInterval = appState.preferences.autoPingInterval; @@ -1168,13 +2085,23 @@ class _SettingsScreenState extends State { } final tile = RadioListTile( - title: Text('$interval seconds'), + title: Text( + '$interval seconds', + style: const TextStyle( + fontSize: 17, fontWeight: FontWeight.bold), + ), subtitle: isDisabled ? const Text( 'Set by Regional Admin — slower intervals reduce congestion in your region', - style: TextStyle(color: Colors.amber), + style: TextStyle(fontSize: 12, color: Colors.amber), ) - : Text(description), + : Text( + description, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), value: interval, ); @@ -1200,10 +2127,48 @@ class _SettingsScreenState extends State { ); } + void _showDistanceSelector(BuildContext context, AppStateProvider appState) { + final currentDistance = appState.preferences.minPingDistanceMeters; + final controller = TextEditingController(text: currentDistance.toString()); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Min Ping Distance'), + content: TextField( + controller: controller, + keyboardType: TextInputType.number, + autofocus: true, + decoration: const InputDecoration( + suffixText: 'meters', + helperText: 'Minimum ${MinPingDistance.min}m', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final value = int.tryParse(controller.text); + if (value != null && value >= MinPingDistance.min) { + appState.updatePreferences( + appState.preferences.copyWith(minPingDistanceMeters: value), + ); + Navigator.pop(context); + } + }, + child: const Text('Save'), + ), + ], + ), + ); + } + void _showRepeaterIdDialog(BuildContext context, AppStateProvider appState) { - final effectiveBytes = appState.effectiveHopBytes; - final maxHexChars = effectiveBytes * 2; - final hintText = 'F' * maxHexChars; + const maxHexChars = 6; + const hintText = 'FFFFFF'; final controller = TextEditingController( text: appState.preferences.ignoreRepeaterId ?? '', @@ -1217,21 +2182,22 @@ class _SettingsScreenState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Enter the repeater ID of your CARpeater ($maxHexChars hex digits):'), + const Text('Enter the full 3-byte repeater ID (6 hex digits):'), const SizedBox(height: 16), TextField( controller: controller, - decoration: InputDecoration( + decoration: const InputDecoration( labelText: 'CARpeater ID', hintText: hintText, prefixText: '0x', - border: const OutlineInputBorder(), + border: OutlineInputBorder(), ), maxLength: maxHexChars, textCapitalization: TextCapitalization.characters, onChanged: (value) { // Keep only valid hex characters - final filtered = value.toUpperCase().replaceAll(RegExp(r'[^0-9A-F]'), ''); + final filtered = + value.toUpperCase().replaceAll(RegExp(r'[^0-9A-F]'), ''); if (filtered != value) { controller.value = controller.value.copyWith( text: filtered, @@ -1242,7 +2208,9 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 8), Text( - 'Multi-hop packets through your CARpeater will be stripped to report the underlying repeater with null signal data. Single-hop CARpeater packets are still dropped.', + 'Enter all 6 hex digits of your CARpeater\'s ID. ' + 'The app will automatically truncate to match your region\'s hop byte size (1, 2, or 3 bytes). ' + 'Multi-hop packets through your CARpeater will be stripped to report the underlying repeater.', style: TextStyle( fontSize: 12, color: Colors.grey[600], @@ -1258,9 +2226,8 @@ class _SettingsScreenState extends State { TextButton( onPressed: () { final value = controller.text.trim().toUpperCase(); - // Accept hex IDs of any valid even length (2, 4, or 6 chars) final isValidHex = value.isEmpty || - (value.length % 2 == 0 && value.length <= 6 && + (value.length == maxHexChars && RegExp(r'^[0-9A-F]+$').hasMatch(value)); if (isValidHex) { @@ -1274,7 +2241,8 @@ class _SettingsScreenState extends State { ); Navigator.pop(context); } else { - AppToast.warning(context, 'Invalid hex value. Use $maxHexChars hex digits.'); + AppToast.warning( + context, 'Please enter exactly 6 hex digits (3-byte ID).'); } }, child: const Text('Save'), @@ -1284,7 +2252,8 @@ class _SettingsScreenState extends State { ); } - Future _pickRouteFile(BuildContext context, AppStateProvider appState) async { + Future _pickRouteFile( + BuildContext context, AppStateProvider appState) async { try { debugLog('[SETTINGS] Opening file picker...'); @@ -1302,9 +2271,8 @@ class _SettingsScreenState extends State { if (result != null && result.files.isNotEmpty) { debugLog('[SETTINGS] File picked: ${result.files.first.name}'); final file = result.files.first; - final content = file.bytes != null - ? String.fromCharCodes(file.bytes!) - : null; + final content = + file.bytes != null ? String.fromCharCodes(file.bytes!) : null; if (content != null && context.mounted) { debugLog('[SETTINGS] File content loaded, ${content.length} chars'); @@ -1338,7 +2306,8 @@ class _SettingsScreenState extends State { ); } - void _processRouteFile(BuildContext context, AppStateProvider appState, String content, String filename) { + void _processRouteFile(BuildContext context, AppStateProvider appState, + String content, String filename) { debugLog('[SETTINGS] Calling loadSimulatorRoute...'); final success = appState.loadSimulatorRoute( content, @@ -1359,38 +2328,69 @@ class _SettingsScreenState extends State { } } - Future _uploadOfflineSession(BuildContext context, AppStateProvider appState, String filename) async { - // Show loading indicator - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Row( - children: [ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), - ), - SizedBox(width: 12), - Text('Uploading session...'), - ], + Future _uploadOfflineSession( + BuildContext context, AppStateProvider appState, String filename) async { + // Progress text notifier for updating dialog without rebuilding screen + final progressNotifier = ValueNotifier('Authenticating...'); + + // Show non-dismissible progress dialog + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => PopScope( + canPop: false, + child: AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + const Text( + 'Uploading session...', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 4), + Text( + filename, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + const SizedBox(height: 8), + ValueListenableBuilder( + valueListenable: progressNotifier, + builder: (_, status, __) => Text( + status, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + ), + ], + ), ), - duration: Duration(seconds: 30), // Will be dismissed when upload completes ), ); - final result = await appState.uploadOfflineSessionWithAuth(filename); + final result = await appState.uploadOfflineSessionWithAuth( + filename, + onProgress: (status) => progressNotifier.value = status, + ); + // Close progress dialog if (context.mounted) { - // Dismiss loading indicator - ScaffoldMessenger.of(context).hideCurrentSnackBar(); + Navigator.of(context).pop(); + } + + progressNotifier.dispose(); - // Show result + if (context.mounted) { + // Show result via SnackBar String message; Color backgroundColor; switch (result) { case OfflineUploadResult.success: - message = 'Uploaded: $filename'; + message = 'Upload Success'; backgroundColor = Colors.green; break; case OfflineUploadResult.notFound: @@ -1405,10 +2405,26 @@ class _SettingsScreenState extends State { message = 'Authentication failed - Advert your device on the mesh'; backgroundColor = Colors.red; break; + case OfflineUploadResult.networkError: + message = 'Network error - tap again to retry'; + backgroundColor = Colors.orange; + break; + case OfflineUploadResult.gpsRequired: + message = 'GPS required - enable location services to upload'; + backgroundColor = Colors.red; + break; case OfflineUploadResult.partialFailure: - message = 'Partial upload - some pings failed'; + message = 'Partial upload - tap again to retry remaining pings'; + backgroundColor = Colors.orange; + break; + case OfflineUploadResult.uploadInProgress: + message = 'Another upload is already in progress'; backgroundColor = Colors.orange; break; + case OfflineUploadResult.zoneDisabled: + message = 'Upload failed - wardriving is disabled in this zone'; + backgroundColor = Colors.red; + break; } ScaffoldMessenger.of(context).showSnackBar( @@ -1421,7 +2437,8 @@ class _SettingsScreenState extends State { } } - void _confirmDeleteOfflineSession(BuildContext context, AppStateProvider appState, String filename) { + void _confirmDeleteOfflineSession( + BuildContext context, AppStateProvider appState, String filename) { showDialog( context: context, builder: (context) => AlertDialog( @@ -1447,9 +2464,11 @@ class _SettingsScreenState extends State { ); } - void _downloadOfflineSession(BuildContext context, AppStateProvider appState, String filename) { + Future _downloadOfflineSession( + BuildContext context, AppStateProvider appState, String filename) async { try { - final sessionData = appState.offlineSessionService.getSessionData(filename); + final sessionData = + appState.offlineSessionService.getSessionData(filename); if (sessionData == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -1461,7 +2480,8 @@ class _SettingsScreenState extends State { } // Convert to pretty JSON - final jsonString = const JsonEncoder.withIndent(' ').convert(sessionData); + final jsonString = + const JsonEncoder.withIndent(' ').convert(sessionData); if (kIsWeb && isWebFileHelpersAvailable) { // Web: Create a blob and trigger download @@ -1481,13 +2501,9 @@ class _SettingsScreenState extends State { ); } } else { - // Mobile: Not yet implemented - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Mobile download coming soon - use web version'), - duration: Duration(seconds: 3), - ), - ); + // Mobile: write the JSON to a temp file and open the native share + // sheet (Save to Files, Drive, email, …) — mirrors the debug-log share. + await appState.shareOfflineSession(filename); } } catch (e) { if (context.mounted) { @@ -1528,7 +2544,8 @@ class _SettingsScreenState extends State { } /// Show debug log viewer dialog - void _showLogViewer(BuildContext context, AppStateProvider appState, File file) async { + void _showLogViewer( + BuildContext context, AppStateProvider appState, File file) async { await appState.viewDebugLog(file); if (!context.mounted) return; @@ -1560,7 +2577,358 @@ class _SettingsScreenState extends State { ); } - void _showCloseAppConfirmation(BuildContext context, AppStateProvider appState) { + void _showCustomApiDisclaimer( + BuildContext context, AppStateProvider appState) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.warning_amber, size: 24, color: Colors.amber), + SizedBox(width: 8), + Flexible(child: Text('Third-Party Data Sharing')), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Enabling this feature will forward your wardriving data to a ' + 'third-party server that is not operated by MeshMapper.', + ), + SizedBox(height: 12), + Text( + 'The following data will be shared:\n' + '\u2022 GPS coordinates (latitude/longitude)\n' + '\u2022 Repeater IDs and signal data\n' + '\u2022 Device antenna and power settings\n' + '\u2022 Timestamps', + style: TextStyle(fontSize: 13), + ), + SizedBox(height: 12), + Text( + 'MeshMapper is not responsible for how third parties handle ' + 'your data. Only enable this if you trust the endpoint operator.', + style: TextStyle(fontSize: 13, color: Colors.amber), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + appState.updatePreferences( + appState.preferences.copyWith( + customApiDisclaimerAccepted: true, + customApiEnabled: true, + ), + ); + }, + child: const Text('I Understand, Enable'), + ), + ], + ), + ); + } + + void _showCustomApiUrlDialog( + BuildContext context, AppStateProvider appState) { + final controller = TextEditingController( + text: appState.preferences.customApiUrl ?? '', + ); + String? errorText; + + showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + title: const Text('Custom API URL'), + content: TextField( + controller: controller, + autofocus: true, + keyboardType: TextInputType.url, + decoration: InputDecoration( + labelText: 'Endpoint URL', + hintText: 'https://api.example.com/wardrive', + helperText: 'HTTPS required', + errorText: errorText, + border: const OutlineInputBorder(), + ), + onChanged: (_) { + if (errorText != null) setState(() => errorText = null); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final url = controller.text.trim(); + if (url.isEmpty) { + setState(() => errorText = 'URL is required'); + return; + } + final uri = Uri.tryParse(url); + if (uri == null || !uri.hasScheme || !uri.hasAuthority) { + setState(() => errorText = 'Invalid URL'); + return; + } + if (uri.scheme != 'https') { + setState(() => errorText = 'HTTPS is required'); + return; + } + Navigator.pop(context); + appState.updatePreferences( + appState.preferences.copyWith(customApiUrl: url), + ); + }, + child: const Text('Save'), + ), + ], + ), + ), + ); + } + + void _showCustomApiKeyDialog( + BuildContext context, AppStateProvider appState) { + final controller = TextEditingController( + text: appState.preferences.customApiKey ?? '', + ); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Custom API Key'), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration( + labelText: 'API Key', + hintText: 'Enter your API key', + border: OutlineInputBorder(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final key = controller.text.trim(); + if (key.isEmpty) return; + Navigator.pop(context); + appState.updatePreferences( + appState.preferences.copyWith(customApiKey: key), + ); + }, + child: const Text('Save'), + ), + ], + ), + ); + } + + Future _importCustomApiFromClipboard( + BuildContext context, AppStateProvider appState) async { + final clipData = await Clipboard.getData('text/plain'); + final text = clipData?.text?.trim(); + + if (text == null || text.isEmpty) { + if (context.mounted) AppToast.error(context, 'Clipboard is empty'); + return; + } + + // Expected format: meshmapper://custom-api?url=example.com/api.php&key=xxxxxxxxxx + if (!text.startsWith('meshmapper://')) { + if (context.mounted) { + AppToast.error(context, 'No meshmapper:// link found in clipboard'); + } + return; + } + + try { + final uri = Uri.parse(text); + final rawUrl = uri.queryParameters['url']; + final key = uri.queryParameters['key']; + + if (rawUrl == null || rawUrl.isEmpty) { + if (context.mounted) { + AppToast.error(context, 'Link is missing the url parameter'); + } + return; + } + if (key == null || key.isEmpty) { + if (context.mounted) { + AppToast.error(context, 'Link is missing the key parameter'); + } + return; + } + + final fullUrl = 'https://$rawUrl'; + + // Validate the constructed URL + final parsed = Uri.tryParse(fullUrl); + if (parsed == null || !parsed.hasAuthority) { + if (context.mounted) { + AppToast.error(context, 'Invalid URL in link: $rawUrl'); + } + return; + } + + appState.updatePreferences( + appState.preferences.copyWith( + customApiUrl: fullUrl, + customApiKey: key, + ), + ); + + if (context.mounted) { + AppToast.success(context, 'Imported endpoint: $rawUrl'); + } + debugLog('[CUSTOM API] Imported endpoint from clipboard: $fullUrl'); + } catch (e) { + debugError('[CUSTOM API] Failed to parse clipboard link: $e'); + if (context.mounted) { + AppToast.error(context, 'Invalid meshmapper:// link'); + } + } + } + + Future _startPortalSignIn( + BuildContext context, AppStateProvider appState) async { + debugLog('[ACCOUNT] Opening the portal sign-in page'); + final launched = await appState.beginPortalSignIn(); + if (!context.mounted) return; + if (launched) { + AppToast.info(context, 'Finish signing in, then return to the app'); + } else { + AppToast.error(context, 'Could not open the browser'); + } + } + + Future _linkPortalDevice( + BuildContext context, AppStateProvider appState) async { + AppToast.info(context, 'Linking this device...'); + final outcome = await appState.startManualDeviceLink(); + if (!context.mounted) return; + switch (outcome.status) { + case PortalLinkStatus.linked: + AppToast.success(context, + 'Linked to ${outcome.accountName ?? 'your MeshMapper account'}'); + case PortalLinkStatus.adoptionRequired: + AppToast.warning( + context, + 'Claim your ${outcome.adoptionDeviceCount} placeholder device(s) at ' + 'portal.meshmapper.net first', + duration: const Duration(seconds: 6), + ); + case PortalLinkStatus.alreadyLinkedOtherAccount: + AppToast.error(context, 'This radio is linked to a different account'); + case PortalLinkStatus.unauthorized: + AppToast.error(context, 'Signed out — please sign in again'); + case PortalLinkStatus.skipped: + case PortalLinkStatus.failed: + // A manual tap deserves feedback even though the automatic path is + // deliberately silent. When the portal named a wait, say it: retrying + // inside the block only re-arms a fresh penalty. + final wait = outcome.retryAfter; + if (wait != null) { + AppToast.warning( + context, 'Too many attempts. Try again in ${_waitLabel(wait)}.'); + } else { + AppToast.error(context, 'Could not link right now — try again later'); + } + } + } + + /// Round a backoff up to a whole unit the user can act on. + String _waitLabel(Duration wait) { + if (wait.inSeconds < 60) return '${wait.inSeconds}s'; + return '${(wait.inSeconds / 60).ceil()} min'; + } + + Future _unlinkPortalDevice( + BuildContext context, AppStateProvider appState) async { + final ok = await appState.unlinkCurrentDevice(); + if (!context.mounted) return; + if (ok) { + AppToast.success(context, 'Device unlinked'); + } else { + AppToast.error(context, 'Could not unlink right now'); + } + } + + Future _refreshPortalAccount( + BuildContext context, AppStateProvider appState) async { + await appState.refreshPortalAccount(force: true); + if (!context.mounted) return; + // A rate-limited refresh makes no request, so the count below would be the + // same stale number every tap, which reads as a dead button and invites + // more tapping. Every tap during a block extends it server-side. + final backoff = appState.portalRefreshBackoff; + if (backoff != null) { + AppToast.error(context, + 'Too many refreshes. Try again in ${_formatBackoff(backoff)}.'); + return; + } + AppToast.simple( + context, '${appState.portalLinkedDeviceCount} linked device(s)'); + } + + /// Rounded up: telling someone to wait "0 minutes" is worse than telling + /// them to wait a beat longer than they have to. + String _formatBackoff(Duration backoff) { + if (backoff.inSeconds < 60) return '${backoff.inSeconds + 1} seconds'; + final minutes = (backoff.inSeconds / 60).ceil(); + return minutes == 1 ? '1 minute' : '$minutes minutes'; + } + + Future _resetPortalDeclines( + BuildContext context, AppStateProvider appState) async { + await appState.resetLinkPromptDeclines(); + if (!context.mounted) return; + AppToast.success(context, 'Link prompts re-enabled'); + } + + void _showPortalSignOutConfirmation( + BuildContext context, AppStateProvider appState) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Sign Out'), + content: const Text( + 'Sign out of MyMeshMapper on this phone? Your radios stay linked to ' + 'your account, and wardriving keeps working.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(dialogContext); + appState.portalSignOut(); + }, + style: TextButton.styleFrom(foregroundColor: Colors.orange), + child: const Text('Sign Out'), + ), + ], + ), + ); + } + + void _showCloseAppConfirmation( + BuildContext context, AppStateProvider appState) { final isConnected = appState.isConnected; showDialog( @@ -1640,7 +3008,9 @@ class _BackgroundModeToggleState extends State<_BackgroundModeToggle> Future _requestPermission() async { // Show prominent disclosure before requesting background location - final accepted = await PermissionDisclosureService.showBackgroundLocationDisclosure(context); + final accepted = + await PermissionDisclosureService.showBackgroundLocationDisclosure( + context); if (!accepted) { return; // User declined } @@ -1721,29 +3091,7 @@ class _BackgroundModeToggleState extends State<_BackgroundModeToggle> Icons.location_on, color: _hasAlwaysPermission ? Colors.green : null, ), - title: Row( - children: [ - const Text('Background Location'), - if (_hasAlwaysPermission) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.green, - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'ALWAYS', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ), - ], - ], - ), + title: const Text('Background Location'), subtitle: Text( _hasAlwaysPermission ? 'Location tracking works in background' @@ -1767,22 +3115,96 @@ class _BackgroundModeToggleState extends State<_BackgroundModeToggle> /// Widget for displaying an offline session in the list class _OfflineSessionTile extends StatelessWidget { final OfflineSession session; + final bool uploadEnabled; final VoidCallback onUpload; final VoidCallback onDelete; final VoidCallback onDownload; const _OfflineSessionTile({ required this.session, + this.uploadEnabled = true, required this.onUpload, required this.onDelete, required this.onDownload, }); + bool get _hasSummary => + (session.placementCounts?.isNotEmpty ?? false) || + ((session.tooFarRegion ?? 0) > 0); + + /// e.g. "DSA 88 · EMA 157 · too far 3" + String _placementSummary() { + final parts = []; + final pc = session.placementCounts; + if (pc != null && pc.isNotEmpty) { + final entries = pc.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + parts.addAll(entries.map((e) => '${e.key} ${e.value}')); + } + final tf = session.tooFarRegion ?? 0; + if (tf > 0) parts.add('too far $tf'); + return parts.join(' · '); + } + + void _showSummaryDialog(BuildContext context) { + final pc = session.placementCounts ?? {}; + final tf = session.tooFarRegion ?? 0; + final entries = pc.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Upload Summary'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(session.filename, + style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 10), + if (entries.isEmpty) + const Text('No regional placement recorded.') + else + ...entries.map((e) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(e.key, + style: + const TextStyle(fontWeight: FontWeight.w600)), + Text('${e.value} pings'), + ], + ), + )), + if (tf > 0) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + '$tf ping(s) dropped — more than 50 km outside every region', + style: TextStyle(color: Colors.orange.shade800, fontSize: 12), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Close'), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { final isUploaded = session.uploaded; return ListTile( + onTap: (isUploaded && _hasSummary) + ? () => _showSummaryDialog(context) + : null, leading: Icon( isUploaded ? Icons.cloud_done : Icons.cloud_off, color: isUploaded ? Colors.green : Colors.orange, @@ -1793,9 +3215,12 @@ class _OfflineSessionTile extends StatelessWidget { children: [ Text('${session.pingCount} pings • ${session.displayDate}'), if (isUploaded) - const Text( - 'Uploaded', - style: TextStyle(color: Colors.green, fontSize: 12, fontWeight: FontWeight.w500), + Text( + _hasSummary ? 'Uploaded · ${_placementSummary()}' : 'Uploaded', + style: const TextStyle( + color: Colors.green, + fontSize: 12, + fontWeight: FontWeight.w500), ), if (session.deviceName != null) Text( @@ -1819,9 +3244,9 @@ class _OfflineSessionTile extends StatelessWidget { if (!isUploaded) IconButton( icon: const Icon(Icons.cloud_upload), - onPressed: onUpload, + onPressed: uploadEnabled ? onUpload : null, tooltip: 'Upload session', - color: Colors.green, + color: uploadEnabled ? Colors.green : Colors.grey, ), // Delete button - always available IconButton( @@ -1835,3 +3260,54 @@ class _OfflineSessionTile extends StatelessWidget { ); } } + +/// Chomper icon widget for the GPS marker selector +class _ChomperIcon extends StatelessWidget { + const _ChomperIcon(); + + @override + Widget build(BuildContext context) { + return CustomPaint( + size: const Size(24, 24), + painter: _ChomperIconPainter( + color: IconTheme.of(context).color ?? Colors.grey, + ), + ); + } +} + +class _ChomperIconPainter extends CustomPainter { + final Color color; + const _ChomperIconPainter({required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + const radius = 10.0; + + const mouthAngle = 70.0 * (math.pi / 180); + // Mouth faces right for the settings icon (natural reading direction) + const startAngle = mouthAngle / 2; + const sweepAngle = 2 * math.pi - mouthAngle; + + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + final path = Path() + ..moveTo(cx, cy) + ..arcTo( + Rect.fromCircle(center: Offset(cx, cy), radius: radius), + startAngle, + sweepAngle, + false, + ) + ..close(); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant _ChomperIconPainter oldDelegate) => + color != oldDelegate.color; +} diff --git a/lib/screens/watch_diagnostics_screen.dart b/lib/screens/watch_diagnostics_screen.dart new file mode 100644 index 00000000..dbd41294 --- /dev/null +++ b/lib/screens/watch_diagnostics_screen.dart @@ -0,0 +1,193 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../services/watch/watch_bridge_service.dart'; + +/// On-device evidence for why the phone is or is not sending to Apple Watch. +/// +/// The bridge remains the single source of truth so opening this route cannot +/// accidentally create a second connectivity policy that drifts from the send +/// gate it is meant to explain. +class WatchDiagnosticsScreen extends StatefulWidget { + const WatchDiagnosticsScreen({ + super.key, + required this.bridge, + }); + + final WatchBridgeService bridge; + + @override + State createState() => _WatchDiagnosticsScreenState(); +} + +class _WatchDiagnosticsScreenState extends State { + Timer? _clockTimer; + var _secondsSinceRefresh = 0; + var _refreshing = false; + + @override + void initState() { + super.initState(); + unawaited(_refresh()); + _clockTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + setState(() => _secondsSinceRefresh++); + + // WCSession status properties are local reads. Poll only while this + // diagnostic is visible so reachability stays live without adding work + // to snapshot scheduling or turning normal app use into a hot path. + if (_secondsSinceRefresh >= 5) { + _secondsSinceRefresh = 0; + unawaited(widget.bridge.refreshAvailability()); + } + }); + } + + @override + void dispose() { + _clockTimer?.cancel(); + super.dispose(); + } + + Future _refresh() async { + if (_refreshing) return; + setState(() => _refreshing = true); + try { + await widget.bridge.refreshAvailability(); + } finally { + if (mounted) setState(() => _refreshing = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + toolbarHeight: 40, + title: const Text('Watch Connectivity', style: TextStyle(fontSize: 18)), + actions: [ + TextButton.icon( + onPressed: _refreshing ? null : _refresh, + icon: _refreshing + ? const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh), + label: const Text('Refresh'), + ), + ], + ), + body: ValueListenableBuilder( + valueListenable: widget.bridge.diagnostics, + builder: (context, status, _) { + final failing = status.failingSyncConditions; + final gateDetail = status.canSync + ? 'activated, paired, and installed are all true' + : failing.isEmpty + ? 'The iOS platform gate is unavailable' + : 'Failing ${failing.length == 1 ? 'condition' : 'conditions'}: ' + '${failing.join(', ')}'; + + return ListView( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), + children: [ + _buildSection(context, 'Native WCSession', [ + _statusTile('supported', status.supported), + _statusTile('paired', status.paired), + _statusTile('installed', status.installed), + _statusTile('reachable', status.reachable), + _statusTile('activated', status.activated), + ]), + _buildSection(context, 'Dart Bridge', [ + _statusTile('canSync', status.canSync), + ListTile( + leading: Icon( + status.canSync ? Icons.check_circle : Icons.error_outline, + color: status.canSync ? Colors.green : Colors.orange, + ), + title: const Text('Sync gate'), + subtitle: Text(gateDetail), + ), + ]), + _buildSection(context, 'Delivery History', [ + ListTile( + leading: const Icon(Icons.send_outlined), + title: const Text('Last successful send'), + subtitle: Text(_timeSince(status.lastSuccessfulSendAt)), + ), + ListTile( + leading: const Icon(Icons.sync_alt), + title: const Text('Last availability change'), + subtitle: Text(_timeSince(status.lastAvailabilityChangedAt)), + ), + ListTile( + leading: const Icon(Icons.fact_check_outlined), + title: const Text('Last send outcome'), + subtitle: Text(switch (status.lastSendDelivered) { + true => 'Delivered (delivered == true)', + false => 'Refused (delivered != true)', + null => 'No send attempted this run', + }), + ), + ]), + ], + ); + }, + ), + ); + } + + Widget _statusTile(String name, bool value) { + return ListTile( + leading: Icon( + value ? Icons.check_circle_outline : Icons.cancel_outlined, + color: value ? Colors.green : Colors.orange, + ), + title: Text(name), + trailing: Text(value ? 'true' : 'false'), + ); + } + + String _timeSince(DateTime? timestamp) { + if (timestamp == null) return 'Not recorded this run'; + final elapsed = DateTime.now().difference(timestamp); + if (elapsed.inSeconds < 5) return 'Just now'; + if (elapsed.inMinutes < 1) return '${elapsed.inSeconds}s ago'; + if (elapsed.inHours < 1) return '${elapsed.inMinutes}m ago'; + if (elapsed.inDays < 1) { + return '${elapsed.inHours}h ${elapsed.inMinutes.remainder(60)}m ago'; + } + return '${elapsed.inDays}d ${elapsed.inHours.remainder(24)}h ago'; + } + + Widget _buildSection( + BuildContext context, String title, List children) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + ...children, + const SizedBox(height: 4), + ], + ), + ), + ); + } +} diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index 89b040fd..0ecbbd2d 100644 --- a/lib/services/api_queue_service.dart +++ b/lib/services/api_queue_service.dart @@ -1,10 +1,12 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:hive/hive.dart'; import '../models/api_queue_item.dart'; import '../utils/debug_logger_io.dart'; import 'api_service.dart'; +import 'custom_api_service.dart'; /// API queue service with batch upload and retry logic /// Ported from apiQueue and batchUpload() in wardrive.js @@ -29,6 +31,9 @@ class ApiQueueService { bool _isUploading = false; bool _isRecovering = false; + // In-memory fallback when Hive is corrupted/unavailable + final List _memoryQueue = []; + // Offline mode bool offlineMode = false; final List> _offlinePings = []; @@ -39,8 +44,11 @@ class ApiQueueService { /// Callback for queue updates void Function(int queueSize)? onQueueUpdated; - /// Callback for successful uploads (passes count of items uploaded) - void Function(int uploadedCount)? onUploadSuccess; + /// Callback for successful uploads. Passes the count AND the uploaded items + /// so the listener can compute which coverage tiles the batch touched (the + /// post-wardrive vector tile refresh needs the ping coordinates). + void Function(int uploadedCount, List uploadedItems)? + onUploadSuccess; /// Callback when persistence fails (for user-visible error logging) void Function(String errorMessage)? onPersistenceError; @@ -48,6 +56,9 @@ class ApiQueueService { /// Callback when storage was cleaned up (for user-visible info logging) void Function(String infoMessage)? onStorageCleanup; + /// Custom API service for forwarding pings to third-party endpoint + CustomApiService? customApiService; + /// Number of pings accumulated in current offline session int get offlinePingCount => _offlinePings.length; @@ -72,13 +83,15 @@ class ApiQueueService { // Pings without a valid session cannot be uploaded, so delete them try { if (_box != null && _box!.isNotEmpty) { - debugLog('[API QUEUE] Clearing ${_box!.length} stale items from previous session'); + debugLog( + '[API QUEUE] Clearing ${_box!.length} stale items from previous session'); await _box!.clear(); } } catch (e) { debugError('[API QUEUE] Failed to clear stale items: $e - recovering'); await _recoverBox(); } + _memoryQueue.clear(); _rxBuffer.clear(); _offlinePings.clear(); @@ -100,10 +113,12 @@ class ApiQueueService { debugLog('[API QUEUE] Hive box "$_boxName" opened successfully'); return box; } on TimeoutException { - debugError('[API QUEUE] Hive box "$_boxName" open timed out after ${timeout.inSeconds}s - attempting recovery'); + debugError( + '[API QUEUE] Hive box "$_boxName" open timed out after ${timeout.inSeconds}s - attempting recovery'); return _attemptRecovery(timeout); } catch (e) { - debugError('[API QUEUE] Hive box "$_boxName" failed to open: $e - attempting recovery'); + debugError( + '[API QUEUE] Hive box "$_boxName" failed to open: $e - attempting recovery'); return _attemptRecovery(timeout); } } @@ -124,10 +139,12 @@ class ApiQueueService { debugLog('[API QUEUE] Hive box "$_boxName" opened after recovery'); return box; } catch (e) { - debugError('[API QUEUE] Recovery failed for "$_boxName": $e - operating without persistence'); + debugError( + '[API QUEUE] Recovery failed for "$_boxName": $e - operating without persistence'); // Notify user of persistence failure - onPersistenceError?.call('Queue storage unavailable - pings will not persist if app closes'); + onPersistenceError?.call( + 'Queue storage unavailable - pings will not persist if app closes'); return null; } @@ -142,7 +159,8 @@ class ApiQueueService { _isRecovering = true; try { - debugLog('[API QUEUE] Runtime corruption detected - recovering box "$_boxName"...'); + debugLog( + '[API QUEUE] Runtime corruption detected - recovering box "$_boxName"...'); // Close the corrupt box try { @@ -160,16 +178,19 @@ class ApiQueueService { _box = box; debugLog('[API QUEUE] Box recovered successfully'); } catch (e) { - debugError('[API QUEUE] Runtime recovery failed: $e - operating without persistence'); + debugError( + '[API QUEUE] Runtime recovery failed: $e - operating without persistence'); _box = null; - onPersistenceError?.call('Queue storage unavailable - pings will not persist if app closes'); + onPersistenceError?.call( + 'Queue storage unavailable - pings will not persist if app closes'); } finally { _isRecovering = false; } } /// Wrap a write operation with corruption recovery and single retry - Future _safeWrite(Future Function(Box box) operation) async { + Future _safeWrite( + Future Function(Box box) operation) async { final box = _box; if (box == null) return false; @@ -207,8 +228,8 @@ class ApiQueueService { } } - /// Get current queue size - int get queueSize => _safeRead((box) => box.length, 0); + /// Get current queue size (Hive + in-memory fallback) + int get queueSize => _safeRead((box) => box.length, 0) + _memoryQueue.length; /// Enqueue a TX ping /// heardRepeats format: "4e(12.25),77(12.25)" or "None" @@ -219,6 +240,9 @@ class ApiQueueService { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, + int? pingCounter, + String? wireTag, }) async { final item = ApiQueueItem.fromTx( latitude: latitude, @@ -227,6 +251,9 @@ class ApiQueueService { timestamp: timestamp, externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, + pingCounter: pingCounter, + wireTag: wireTag, ); // In offline mode, accumulate to offline pings list instead of queue @@ -236,8 +263,15 @@ class ApiQueueService { return; } - await _safeWrite((box) => box.add(item)); - debugLog('[API QUEUE] TX enqueued: $heardRepeats (queue size: $queueSize)'); + final wrote = await _safeWrite((box) => box.add(item)); + if (!wrote) { + _memoryQueue.add(item); + debugLog( + '[API QUEUE] TX enqueued (memory fallback): $heardRepeats (queue size: $queueSize)'); + } else { + debugLog( + '[API QUEUE] TX enqueued: $heardRepeats (queue size: $queueSize)'); + } onQueueUpdated?.call(queueSize); _pingFlushTimer?.cancel(); _pingFlushTimer = Timer(const Duration(seconds: 5), () { @@ -257,6 +291,7 @@ class ApiQueueService { required String repeaterId, required bool externalAntenna, int? noiseFloor, + double? power, }) async { final item = ApiQueueItem.fromRx( latitude: latitude, @@ -265,6 +300,7 @@ class ApiQueueService { timestamp: timestamp, externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); // In offline mode, accumulate to offline pings list instead of queue @@ -300,6 +336,7 @@ class ApiQueueService { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) async { final item = ApiQueueItem.fromDisc( latitude: latitude, @@ -313,6 +350,7 @@ class ApiQueueService { timestamp: timestamp, externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); // In offline mode, accumulate to offline pings list instead of queue @@ -322,8 +360,66 @@ class ApiQueueService { return; } - await _safeWrite((box) => box.add(item)); - debugLog('[API QUEUE] DISC enqueued: $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); + final wrote = await _safeWrite((box) => box.add(item)); + if (!wrote) { + _memoryQueue.add(item); + debugLog( + '[API QUEUE] DISC enqueued (memory fallback): $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); + } else { + debugLog( + '[API QUEUE] DISC enqueued: $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)'); + } + onQueueUpdated?.call(queueSize); + _pingFlushTimer?.cancel(); + _pingFlushTimer = Timer(const Duration(seconds: 5), () { + debugLog('[API QUEUE] Ping flush timer fired'); + _flushRxBuffer(); + _uploadBatch(); + }); + } + + /// Enqueue a TRACE ping result (targeted zero-hop trace) + Future enqueueTrace({ + required double latitude, + required double longitude, + required String repeaterId, + required double localSnr, + required int localRssi, + required double remoteSnr, + required int timestamp, + required bool externalAntenna, + int? noiseFloor, + double? power, + }) async { + final item = ApiQueueItem.fromTrace( + latitude: latitude, + longitude: longitude, + repeaterId: repeaterId, + localSnr: localSnr, + localRssi: localRssi, + remoteSnr: remoteSnr, + timestamp: timestamp, + externalAntenna: externalAntenna, + noiseFloor: noiseFloor, + power: power, + ); + + // In offline mode, accumulate to offline pings list instead of queue + if (offlineMode) { + _offlinePings.add(item.toApiJson()); + debugLog('[API QUEUE] TRACE enqueued (offline): $repeaterId'); + return; + } + + final wrote = await _safeWrite((box) => box.add(item)); + if (!wrote) { + _memoryQueue.add(item); + debugLog( + '[API QUEUE] TRACE enqueued (memory fallback): $repeaterId at $latitude, $longitude (queue size: $queueSize)'); + } else { + debugLog( + '[API QUEUE] TRACE enqueued: $repeaterId at $latitude, $longitude (queue size: $queueSize)'); + } onQueueUpdated?.call(queueSize); _pingFlushTimer?.cancel(); _pingFlushTimer = Timer(const Duration(seconds: 5), () { @@ -340,6 +436,7 @@ class ApiQueueService { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) async { final item = ApiQueueItem.fromDiscDrop( latitude: latitude, @@ -347,6 +444,7 @@ class ApiQueueService { timestamp: timestamp, externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); // In offline mode, accumulate to offline pings list instead of queue @@ -356,8 +454,15 @@ class ApiQueueService { return; } - await _safeWrite((box) => box.add(item)); - debugLog('[API QUEUE] DISC drop enqueued at $latitude, $longitude (queue size: $queueSize)'); + final wrote = await _safeWrite((box) => box.add(item)); + if (!wrote) { + _memoryQueue.add(item); + debugLog( + '[API QUEUE] DISC drop enqueued (memory fallback) at $latitude, $longitude (queue size: $queueSize)'); + } else { + debugLog( + '[API QUEUE] DISC drop enqueued at $latitude, $longitude (queue size: $queueSize)'); + } onQueueUpdated?.call(queueSize); _pingFlushTimer?.cancel(); _pingFlushTimer = Timer(const Duration(seconds: 5), () { @@ -386,13 +491,16 @@ class ApiQueueService { final bufferSize = _rxBuffer.length; _rxBuffer.clear(); - // Now add items to the box + // Now add items to the box (or memory fallback) for (final item in itemsToFlush) { final ok = await _safeWrite((box) => box.add(item)); - if (!ok) break; + if (!ok) { + _memoryQueue.add(item); + } } - debugLog('[API QUEUE] Flushed ${itemsToFlush.length} RX items from $bufferSize repeaters to queue'); + debugLog( + '[API QUEUE] Flushed ${itemsToFlush.length} RX items from $bufferSize repeaters to queue'); onQueueUpdated?.call(queueSize); } finally { _isFlushing = false; @@ -424,13 +532,17 @@ class ApiQueueService { await _uploadBatch(); } - /// Upload batch of queued items + /// Upload batch of queued items (from Hive box or in-memory fallback) Future _uploadBatch() async { if (_isUploading) { debugLog('[API QUEUE] Upload skipped: already uploading'); return; } - if (_safeRead((box) => box.isEmpty, true)) { + + final hiveEmpty = _safeRead((box) => box.isEmpty, true); + final memoryEmpty = _memoryQueue.isEmpty; + + if (hiveEmpty && memoryEmpty) { debugLog('[API QUEUE] Upload skipped: queue empty'); return; } @@ -438,21 +550,26 @@ class ApiQueueService { _isUploading = true; try { - // Log if TX items are waiting in hold period - final pendingTx = _safeRead((box) => box.values.where((item) => - item.type == 'TX' && !item.isUploadEligible).length, 0); - if (pendingTx > 0) { - debugLog('[API QUEUE] $pendingTx TX items still in hold period'); - } - - // Get items ready for upload (must pass retry, retry delay, AND upload eligibility checks) - final items = _safeRead((box) => box.values + // Collect items from both Hive and memory queue + final hiveItems = _safeRead( + (box) => box.values + .where((item) => + item.retryCount < _maxRetries && + item.isReadyForRetry && + item.isUploadEligible) + .take(_batchSize) + .toList(), + []); + + final memoryItems = _memoryQueue .where((item) => item.retryCount < _maxRetries && item.isReadyForRetry && item.isUploadEligible) - .take(_batchSize) - .toList(), []); + .take(_batchSize - hiveItems.length) + .toList(); + + final items = [...hiveItems, ...memoryItems]; if (items.isEmpty) { debugLog('[API QUEUE] Upload skipped: no items ready for upload'); @@ -463,37 +580,78 @@ class ApiQueueService { // Convert to API format final pings = items.map((item) => item.toApiJson()).toList(); - // Log each item with external_antenna value + // Log each item with external_antenna value. Token-mode TX entries also log their + // wire_tag + ping_counter so a debug log self-documents any tag collision/drop. for (int i = 0; i < items.length; i++) { final item = items[i]; - debugLog('[API QUEUE] Item ${i + 1}/${items.length}: type=${item.type}, external_antenna=${item.externalAntenna}'); + final tagInfo = item.wireTag != null + ? ', wire_tag=${item.wireTag}, ping_counter=${item.pingCounter}' + : ''; + debugLog( + '[API QUEUE] Item ${i + 1}/${items.length}: type=${item.type}, external_antenna=${item.externalAntenna}$tagInfo'); } - debugLog('[API QUEUE] Uploading ${items.length} items...'); + final memoryCount = memoryItems.length; + if (memoryCount > 0) { + debugLog( + '[API QUEUE] Uploading ${items.length} items ($memoryCount from memory fallback)...'); + } else { + debugLog('[API QUEUE] Uploading ${items.length} items...'); + } // Attempt upload final result = await _apiService.uploadBatch(pings); if (result == UploadResult.success) { final uploadedCount = items.length; - // Remove successful items - for (final item in items) { - await item.delete(); + // Remove successful Hive items + for (final item in hiveItems) { + try { + await item.delete(); + } catch (_) {} + } + // Remove successful memory items + for (final item in memoryItems) { + _memoryQueue.remove(item); } debugLog('[API QUEUE] Upload SUCCESS: deleted $uploadedCount items'); - onUploadSuccess?.call(uploadedCount); + // The network is demonstrably back, so give anything the ladder has + // already written off one more chance. + _reviveFailedItems(); + onUploadSuccess?.call(uploadedCount, items); + // Fire-and-forget: forward to custom API endpoint + customApiService?.forwardPings(pings); } else if (result == UploadResult.nonRetryable) { - // Data is permanently invalid (bad GPS, invalid request, etc.) — discard - for (final item in items) { - await item.delete(); + // Data is permanently invalid — discard + for (final item in hiveItems) { + try { + await item.delete(); + } catch (_) {} } - debugWarn('[API QUEUE] Discarded ${items.length} items (non-retryable error)'); + for (final item in memoryItems) { + _memoryQueue.remove(item); + } + debugWarn( + '[API QUEUE] Discarded ${items.length} items (non-retryable error)'); + } else if (result == UploadResult.unreachable) { + // We never got an answer, so this says nothing about the data. Leave + // retryCount and lastRetryAt alone: spending a retry here meant ~75s + // out of coverage wrote every queued ping off for good (#437). The + // flush cadence is the pacing; there is nothing to back off from. + debugLog( + '[API QUEUE] Upload deferred: ${items.length} items held, no route to server'); } else { // Mark items as retried - for (final item in items) { + for (final item in hiveItems) { item.markRetried(); } - debugLog('[API QUEUE] Upload FAILED: ${items.length} items marked for retry'); + // Memory items: update retry fields directly (no Hive save) + for (final item in memoryItems) { + item.retryCount++; + item.lastRetryAt = DateTime.now(); + } + debugLog( + '[API QUEUE] Upload FAILED: ${items.length} items marked for retry'); } onQueueUpdated?.call(queueSize); @@ -522,6 +680,7 @@ class ApiQueueService { /// Clear all queued items Future clear() async { await _safeWrite((box) => box.clear()); + _memoryQueue.clear(); _rxBuffer.clear(); onQueueUpdated?.call(0); } @@ -539,9 +698,11 @@ class ApiQueueService { final count = queueSize + _rxBuffer.length; if (count > 0) { - debugLog('[API QUEUE] Clearing $count items on disconnect (queue: $queueSize, rxBuffer: ${_rxBuffer.length})'); + debugLog( + '[API QUEUE] Clearing $count items on disconnect (queue: $queueSize, rxBuffer: ${_rxBuffer.length})'); } await _safeWrite((box) => box.clear()); + _memoryQueue.clear(); _rxBuffer.clear(); onQueueUpdated?.call(0); } @@ -555,6 +716,7 @@ class ApiQueueService { debugLog('[API QUEUE] Clearing $count stale items before connect'); } await _safeWrite((box) => box.clear()); + _memoryQueue.clear(); _rxBuffer.clear(); onQueueUpdated?.call(0); @@ -565,12 +727,58 @@ class ApiQueueService { } } + /// Put items the retry ladder has written off back in the running. + /// + /// Called after a successful upload, which is the only proof we have that the + /// server is reachable and answering. Without it [_maxRetries] is a one-way + /// door: nothing resets the counter, so a written-off ping sat in Hive + /// forever, never uploaded and never surfaced (#437). + /// + /// Only items past the ladder are touched. Anything still climbing it is + /// mid-backoff for a reason the server gave us, and is left alone. + void _reviveFailedItems() { + final stranded = failedItems; + if (stranded.isEmpty) return; + + for (final item in stranded) { + item.retryCount = 0; + item.lastRetryAt = null; + if (item.isInBox) { + try { + item.save(); + } catch (_) {} + } + } + debugLog('[API QUEUE] Revived ${stranded.length} items the retry ladder ' + 'had written off'); + } + + /// Every item currently held, Hive and memory alike. + /// + /// Exists so tests can set an item's retry state directly instead of spending + /// 31 seconds of real time climbing the backoff ladder to reach it. + @visibleForTesting + List get heldItems => [ + ..._safeRead((box) => box.values.toList(), []), + ..._memoryQueue, + ]; + /// Get failed items (exceeded max retries) List get failedItems { - return _safeRead( - (box) => box.values.where((item) => item.retryCount >= _maxRetries).toList(), + final hiveItems = _safeRead( + (box) => + box.values.where((item) => item.retryCount >= _maxRetries).toList(), [], ); + final memoryItems = + _memoryQueue.where((item) => item.retryCount >= _maxRetries).toList(); + return [...hiveItems, ...memoryItems]; + } + + /// Get a snapshot of accumulated offline pings without clearing. + /// Used for periodic auto-saves to persist data without losing the in-memory accumulator. + List> getOfflinePingsSnapshot() { + return List>.from(_offlinePings); } /// Get accumulated offline pings and clear the accumulator @@ -586,6 +794,89 @@ class ApiQueueService { _offlinePings.clear(); } + /// Drop every queued item whose wire tag can no longer be validated. + /// + /// A wire tag only re-derives under the session that minted it: the server + /// recomputes it from the session_id the batch is POSTed under and skips the + /// entry entirely on a mismatch, while still returning success, so the app + /// prunes the item as uploaded and the TX ping is lost with no error + /// anywhere (`wardrive-api.php`, action=wire_tag_mismatch). + /// + /// Call this whenever the session id changes underneath a preserved queue. + /// Every item queued at that moment was necessarily minted under the old + /// session (anything minted under the new one is enqueued afterwards), so + /// dropping all tagged items is exact and needs no per-item bookkeeping. + /// + /// Auto-reconnect is the path that matters: it deliberately preserves the + /// queue, and /auth only reuses a session while it is status=1 and + /// unexpired. Otherwise a fresh session_id comes back and everything + /// already queued is stale. + /// + /// Dropping rather than un-tagging is deliberate. Re-minting under the new + /// session would claim a tag that never went out on the air. Stripping the + /// tag sends the ping down the server coords path, where hours (or just a + /// reconnect) later there is no status-4 WAIT row to join, so it inserts as + /// DEAD(3) and renders a GREY "dead" cell for a ping that was actually + /// heard. At roughly 0.2% of TX pings, an honest drop beats a misleading + /// map. + Future dropStaleTaggedItems() async { + final staleHive = _safeRead( + (box) => box.values.where((i) => i.hasWireTag).toList(), + [], + ); + for (final item in staleHive) { + try { + await item.delete(); + } catch (e) { + debugError('[API QUEUE] Failed to drop stale tagged item: $e'); + } + } + + final beforeMemory = _memoryQueue.length; + _memoryQueue.removeWhere((i) => i.hasWireTag); + final dropped = staleHive.length + (beforeMemory - _memoryQueue.length); + + if (dropped > 0) { + debugWarn( + '[API QUEUE] Session changed: dropped $dropped queued TX ping(s) whose wire tag ' + 'was minted under the old session (undeliverable)'); + onQueueUpdated?.call(queueSize); + } + } + + /// Extract all queued items as API JSON without clearing the queue. + /// Used to preserve data before session-expiry disconnect. + /// + /// Tagged TX pings are left OUT of the snapshot. It is bound for offline + /// storage and gets re-uploaded under a brand new `offline-YYYYMMDD-NNNN` + /// session, where the tag cannot re-derive: the server would skip the row + /// while still reporting success, so the ping is lost either way. Preserving + /// it only buys a wire_tag_mismatch warn. RX/DISC/TRACE carry no tag and are + /// preserved exactly as before. + Future>> extractAllAsJson() async { + // Flush RX buffer first so all items are in the main queue + await _flushRxBuffer(); + + final hiveItems = _safeRead( + (box) => box.values.toList(), + [], + ); + + final allItems = [...hiveItems, ..._memoryQueue]; + + if (allItems.isEmpty) return []; + + final deliverable = allItems.where((i) => !i.hasWireTag).toList(); + final skipped = allItems.length - deliverable.length; + if (skipped > 0) { + debugWarn( + '[API QUEUE] Preserving offline: skipped $skipped tagged TX ping(s) that no ' + 'offline session could upload (kept ${deliverable.length} untagged item(s))'); + } + + return deliverable.map((item) => item.toApiJson()).toList(); + } + /// Dispose of resources void dispose() { _batchTimer?.cancel(); diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 72d8300c..52b10928 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -1,5 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; import 'package:http/http.dart' as http; @@ -7,7 +9,47 @@ import '../models/repeater.dart'; import '../utils/debug_logger_io.dart'; /// Result of a batch upload attempt -enum UploadResult { success, retryable, nonRetryable } +/// +/// [unreachable] is deliberately separate from [retryable]: the data is fine, +/// we simply never got an answer. The queue must not spend a retry on it, or a +/// drive through a dead zone burns the whole ladder and strands the pings +/// (#437). +enum UploadResult { + success, + retryable, + unreachable, + sessionError, + nonRetryable +} + +/// The request never reached the server: no coverage, DNS failure, connection +/// reset, TLS handshake, or a timeout waiting for the first byte. +/// +/// Distinct from the server answering with a rejection, which is a verdict on +/// the data itself. +class _RequestNeverReachedServer implements Exception { + final Object cause; + const _RequestNeverReachedServer(this.cause); + + @override + String toString() => 'Request never reached the server: $cause'; +} + +/// Classify a thrown request error as "never got an answer" or not. +/// +/// A FormatException is excluded on purpose: the server did answer, just with +/// something unparseable, which is a verdict-shaped failure and keeps its place +/// on the retry ladder. SocketException and HandshakeException live in dart:io, +/// which this file must not import because it is shared with the web build, so +/// they are matched by type name. +bool _requestNeverReachedServer(Object error) { + if (error is FormatException) return false; + if (error is TimeoutException) return true; + if (error is http.ClientException) return true; + final name = error.runtimeType.toString(); + return name.contains('SocketException') || + name.contains('HandshakeException'); +} /// MeshMapper API service /// Handles communication with the MeshMapper backend @@ -24,6 +66,7 @@ class ApiService { static const String wardriveEndpoint = '$baseUrl/wardrive-api.php/wardrive'; static const String geoAuthStatusUrl = '$baseUrl/wardrive-api.php/status'; static const String geoAuthUrl = '$baseUrl/wardrive-api.php/auth'; + static const String borderUrl = '$baseUrl/wardrive-api.php/border'; /// API key — injected at build time via --dart-define=API_KEY=... static const String apiKey = String.fromEnvironment('API_KEY'); @@ -31,18 +74,44 @@ class ApiService { /// Heartbeat buffer - schedule heartbeat 1 minute before session expiry static const Duration heartbeatBuffer = Duration(minutes: 1); + /// Minimum spacing between heartbeat sends. expires_at is server-clock while + /// the delay math runs on the device clock, so a device running further + /// ahead of the server than TTL minus [heartbeatBuffer] sees every freshly + /// extended expiry as already due. Without this floor the "expired, send + /// immediately" path re-fired the next heartbeat straight from the success + /// response, one POST per network round trip (361k requests in 64 minutes + /// from a single device on 2026-08-29). + static const Duration minHeartbeatSpacing = Duration(seconds: 30); + + /// Circuit breaker: hard cap on scheduled heartbeat sends per minute, + /// independent of the spacing floor. Tripping it pauses the heartbeat lane + /// for a minute instead of letting any future bug storm the server. + static const int maxHeartbeatsPerMinute = 6; + final http.Client _client; - bool _heartbeatEnabled = false; // Track if heartbeat mode is active + bool _heartbeatEnabled = false; // Track if heartbeat mode is active String? _sessionId; bool _txAllowed = false; bool _rxAllowed = false; int? _sessionExpiresAt; + String? _wireKey; // TX wire-tag secret from /auth (null = un-keyed fallback) + int _pingCounter = + 0; // per-session TX counter; resets on fresh /auth, not on heartbeat Timer? _heartbeatTimer; + Timer? _heartbeatRetryTimer; + + int _heartbeatRetryCount = 0; + static const int _maxHeartbeatRetries = 5; + bool _heartbeatInFlight = false; + DateTime? _lastHeartbeatSentAt; + DateTime? _heartbeatWindowStart; + int _heartbeatWindowCount = 0; Function? _onSessionExpiring; List _channels = []; List _scopes = []; bool _enforceHybrid = false; bool _enforceDiscDrop = false; + bool _floodDisabled = false; int _minModeInterval = 15; int _apiHopBytes = 1; @@ -62,6 +131,9 @@ class ApiService { /// Whether discovery drop is enforced by regional admin bool get enforceDiscDrop => _enforceDiscDrop; + /// Whether flood traffic (Active/Hybrid modes) is disabled by regional admin + bool get floodDisabled => _floodDisabled; + /// Minimum auto-ping interval enforced by regional admin (seconds) int get minModeInterval => _minModeInterval; @@ -86,7 +158,8 @@ class ApiService { /// Check if response indicates maintenance mode, trigger callback if so bool _checkMaintenanceMode(Map response) { if (response['maintenance'] == true) { - final message = response['maintenance_message'] as String? ?? 'Service is under maintenance'; + final message = response['maintenance_message'] as String? ?? + 'Service is under maintenance'; final url = response['maintenance_url'] as String?; debugLog('[MAINTENANCE] Maintenance mode detected: $message'); onMaintenanceMode?.call(message, url); @@ -104,7 +177,8 @@ class ApiService { Map? request, dynamic response, }) { - final durationSec = (stopwatch.elapsedMilliseconds / 1000).toStringAsFixed(2); + final durationSec = + (stopwatch.elapsedMilliseconds / 1000).toStringAsFixed(2); String reqSummary; if (request != null) { @@ -131,16 +205,28 @@ class ApiService { /// Check if we have a valid session bool get hasSession => _sessionId != null; - + /// Check if TX is allowed bool get txAllowed => _txAllowed; - + /// Check if RX is allowed bool get rxAllowed => _rxAllowed; - + /// Get session ID String? get sessionId => _sessionId; + /// TX wire-tag secret delivered by /auth (null when the server didn't send one). + String? get wireKey => _wireKey; + + /// Current per-session TX ping counter (peek without incrementing). + int get pingCounter => _pingCounter; + + /// Increment and return the next per-session TX ping counter (1-based). + /// Hard-capped at 2047 (the wire tag's 11-bit counter field) so the packed + /// token can never overflow into the session#/region bits. Reaching the cap + /// needs an ~8.5h continuous session at 15s; a fresh /auth resets it. + int nextPingCounter() => _pingCounter >= 2047 ? 2047 : ++_pingCounter; + /// Get session expiry timestamp int? get sessionExpiresAt => _sessionExpiresAt; @@ -169,30 +255,32 @@ class ApiService { 'key': apiKey, }; - final response = await _client.post( - Uri.parse(geoAuthStatusUrl), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 10)); + final response = await _client + .post( + Uri.parse(geoAuthStatusUrl), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 10)); stopwatch.stop(); if (response.statusCode != 200) { - debugError('[API] /wardrive-api.php/status returned HTTP ${response.statusCode}'); - debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + debugError( + '[API] /wardrive-api.php/status returned HTTP ${response.statusCode}'); + debugError( + '[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); debugError('[API] Response headers: ${response.headers}'); } Map? data; - if (response.statusCode == 200) { + try { data = json.decode(response.body) as Map; - } else if (response.body.isNotEmpty) { - // Try to parse structured error responses (e.g., 403 gps_inaccurate) - try { - data = json.decode(response.body) as Map; - } catch (e) { - debugWarn('[API] Non-JSON response body (HTML error page, etc.): $e'); - } + } on FormatException { + // CDN/proxy can return HTML error pages with HTTP 200 + debugError( + '[API] Non-JSON response from /status (HTTP ${response.statusCode}): ' + '${response.body.length > 200 ? response.body.substring(0, 200) : response.body}'); } _logApiCall( @@ -212,6 +300,79 @@ class ApiService { } } + /// Fetch regional boundary polygons from the MeshMapper API. + /// + /// Returns a list of `{code, polygon}` maps where `polygon` is a list of + /// `[lat, lon]` pairs (as sent by the server). Returns `null` on failure + /// or maintenance mode. + Future>?> fetchBorderPolygons({ + required double lat, + required double lon, + required String appVersion, + }) async { + final stopwatch = Stopwatch()..start(); + try { + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final payload = { + 'lat': lat, + 'lng': lon, + 'ver': appVersion, + 'timestamp': timestamp, + 'key': apiKey, + }; + + final response = await _client + .post( + Uri.parse(borderUrl), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 10)); + + stopwatch.stop(); + + if (response.statusCode != 200) { + debugError( + '[BORDER] /wardrive-api.php/border returned HTTP ${response.statusCode}'); + debugError( + '[BORDER] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + } + + Map? data; + try { + data = json.decode(response.body) as Map; + } on FormatException { + debugError( + '[BORDER] Non-JSON response from /border (HTTP ${response.statusCode}): ' + '${response.body.length > 200 ? response.body.substring(0, 200) : response.body}'); + } + + _logApiCall( + endpoint: '/wardrive-api.php/border', + method: 'POST', + stopwatch: stopwatch, + statusCode: response.statusCode, + request: payload, + response: data, + ); + + if (data == null) return null; + if (_checkMaintenanceMode(data)) return null; + + final polygons = data['polygons']; + if (polygons is! List) return null; + + return polygons + .whereType() + .map((p) => Map.from(p)) + .toList(); + } catch (e) { + stopwatch.stop(); + debugError('[BORDER] POST /wardrive-api.php/border failed: $e'); + return null; + } + } + /// Request authentication with MeshMapper geo-auth API /// Matches requestAuth() in wardrive.js /// @@ -219,20 +380,25 @@ class ApiService { /// @param publicKey Device public key (for existing auth flow) /// @param contactUri Signed contact URI (for registration flow) /// @param offlineMode Set to true when uploading offline session data + /// @param skipSessionStore When true, does not write to shared _sessionId/_txAllowed/etc. Caller manages session locally. + /// @param sessionId Explicit session ID for disconnect. When provided, disconnect uses this instead of _sessionId and skips _clearSession(). /// @returns Map with success, session_id, tx_allowed, rx_allowed, expires_at, reason, message Future?> requestAuth({ required String reason, - String? publicKey, // Now optional - either publicKey or contactUri required - String? contactUri, // NEW: for registration flow + String? publicKey, // Now optional - either publicKey or contactUri required + String? contactUri, // NEW: for registration flow String? who, String? appVersion, double? power, String? iataCode, String? model, + String? radioFreq, double? lat, double? lon, double? accuracyMeters, bool offlineMode = false, + bool skipSessionStore = false, + String? sessionId, }) async { final stopwatch = Stopwatch()..start(); try { @@ -263,9 +429,12 @@ class ApiService { if (who != null) payload['who'] = who; payload['ver'] = appVersion ?? 'UNKNOWN'; - if (power != null) payload['power'] = '${power}w'; // Wattage (0.3w, 0.6w, 1.0w, 2.0w) + if (power != null) { + payload['power'] = '${power}w'; // Wattage (0.3w, 0.6w, 1.0w, 2.0w) + } if (iataCode != null) payload['iata'] = iataCode; if (model != null) payload['model'] = model; + if (radioFreq != null) payload['radio_freq'] = radioFreq; payload['coords'] = { 'lat': lat, 'lng': lon, // Convert lon → lng for API @@ -273,28 +442,33 @@ class ApiService { 'timestamp': DateTime.now().millisecondsSinceEpoch ~/ 1000, }; } else { - // For disconnect: add session_id - payload['session_id'] = _sessionId; + // For disconnect: use explicit sessionId if provided, otherwise shared _sessionId + payload['session_id'] = sessionId ?? _sessionId; } - final response = await _client.post( - Uri.parse(geoAuthUrl), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 10)); + final response = await _client + .post( + Uri.parse(geoAuthUrl), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 10)); stopwatch.stop(); if (response.statusCode != 200) { - debugError('[API] /wardrive-api.php/auth returned HTTP ${response.statusCode}'); - debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + debugError( + '[API] /wardrive-api.php/auth returned HTTP ${response.statusCode}'); + debugError( + '[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); } Map data; try { data = json.decode(response.body) as Map; } on FormatException { - debugError('[API] Non-JSON response from /auth (HTTP ${response.statusCode}): ' + debugError( + '[API] Non-JSON response from /auth (HTTP ${response.statusCode}): ' '${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); rethrow; } @@ -310,67 +484,112 @@ class ApiService { // Store session info on successful connect or register // Note: 'register' now returns full auth response directly (no retry needed) - if ((reason == 'connect' || reason == 'register') && data['success'] == true) { - _sessionId = data['session_id'] as String?; - _txAllowed = data['tx_allowed'] == true; - _rxAllowed = data['rx_allowed'] == true; - _sessionExpiresAt = data['expires_at'] as int?; - - // Parse channels array from auth response - final channelsData = data['channels']; - if (channelsData is List) { - _channels = channelsData.cast().toList(); - debugLog('[API] Regional channels: $_channels'); - } else { - _channels = []; - } + if ((reason == 'connect' || reason == 'register') && + data['success'] == true) { + if (!skipSessionStore) { + // A wire tag only re-derives under the session that minted it. When + // the server hands back a DIFFERENT session id (it reuses one only + // while status=1 and unexpired), anything still sitting in the queue + // was tagged under the old session and would be silently dropped on + // upload, so tell the listener to drop those pings. + final previousSessionId = _sessionId; + _sessionId = data['session_id'] as String?; + if (previousSessionId != null && + _sessionId != null && + previousSessionId != _sessionId) { + debugLog( + '[SESSION] New session id issued (was $previousSessionId, now $_sessionId). ' + 'Queued wire tags are stale'); + onSessionIdChanged?.call(); + } + _txAllowed = data['tx_allowed'] == true; + _rxAllowed = data['rx_allowed'] == true; + _sessionExpiresAt = data['expires_at'] as int?; + + // TX wire-tag key + per-session ping counter. The counter RESUMES from the server's + // resume_counter when our session was reused (force-close + reconnect): resetting to 0 + // here would re-mint wire-tags identical to the pre-restart pings, which the subscriber + // + coverage dedup then silently drop. Absent (new session / old server) -> 0, the + // original behaviour. Log only receipt + length — NEVER the raw key (uploadable logs). + _wireKey = data['wire_key'] as String?; + final resumeCounter = (data['resume_counter'] as num?)?.toInt() ?? 0; + _pingCounter = resumeCounter; + if (resumeCounter > 0) { + debugLog( + '[AUTH] resumed ping counter at $resumeCounter (reused session)'); + } + if (_wireKey != null) { + debugLog('[AUTH] wire-tag key received (len=${_wireKey!.length})'); + } else { + debugLog('[AUTH] no wire-tag key in auth response (un-keyed tag)'); + } - // Parse scopes array from auth response - final scopesData = data['scopes']; - if (scopesData is List && scopesData.isNotEmpty) { - _scopes = scopesData.cast().toList(); - debugLog('[API] Regional scopes: $_scopes'); - } else { - _scopes = []; - } + // Parse channels array from auth response + final channelsData = data['channels']; + if (channelsData is List) { + _channels = channelsData.cast().toList(); + debugLog('[API] Regional channels: $_channels'); + } else { + _channels = []; + } - // Parse enforce_hybrid flag from auth response - _enforceHybrid = data['enforce_hybrid'] == true; - if (_enforceHybrid) { - debugLog('[API] Regional admin enforces hybrid mode'); - } + // Parse scopes array from auth response + final scopesData = data['scopes']; + if (scopesData is List && scopesData.isNotEmpty) { + _scopes = scopesData.cast().toList(); + debugLog('[API] Regional scopes: $_scopes'); + } else { + _scopes = []; + } - // Parse disc_drop flag from auth response - _enforceDiscDrop = data['disc_drop'] == true; - if (_enforceDiscDrop) { - debugLog('[API] Regional admin enforces discovery drop'); - } + // Parse enforce_hybrid flag from auth response + _enforceHybrid = data['enforce_hybrid'] == true; + if (_enforceHybrid) { + debugLog('[API] Regional admin enforces hybrid mode'); + } - // Parse min_mode_interval from auth response - final minInterval = data['min_mode_interval']; - if (minInterval is int && minInterval > 0) { - _minModeInterval = minInterval; - debugLog('[API] Regional admin min interval: ${_minModeInterval}s'); - } else { - _minModeInterval = 15; - } + // Parse disc_drop flag from auth response + _enforceDiscDrop = data['disc_drop'] == true; + if (_enforceDiscDrop) { + debugLog('[API] Regional admin enforces discovery drop'); + } - // Parse hop_bytes from auth response - final hopBytes = data['hop_bytes']; - if (hopBytes is int && hopBytes >= 1 && hopBytes <= 3) { - _apiHopBytes = hopBytes; - if (_apiHopBytes > 1) { - debugLog('[API] Regional admin enforces $_apiHopBytes-byte paths'); + // Parse flood_disabled flag from auth response + _floodDisabled = data['flood_disabled'] == true; + if (_floodDisabled) { + debugLog('[API] Regional admin has disabled flood traffic'); + } + + // Parse min_mode_interval from auth response + final minInterval = data['min_mode_interval']; + if (minInterval is int && minInterval > 0) { + _minModeInterval = minInterval; + debugLog('[API] Regional admin min interval: ${_minModeInterval}s'); + } else { + _minModeInterval = 15; + } + + // Parse hop_bytes from auth response + final hopBytes = data['hop_bytes']; + if (hopBytes is int && hopBytes >= 1 && hopBytes <= 3) { + _apiHopBytes = hopBytes; + if (_apiHopBytes > 1) { + debugLog( + '[API] Regional admin enforces $_apiHopBytes-byte paths'); + } + } else { + _apiHopBytes = 1; } - } else { - _apiHopBytes = 1; - } - // Note: Heartbeat is enabled by AppStateProvider when auto mode starts - // (not on initial auth, since heartbeat is only for auto mode) + // Note: Heartbeat is enabled by AppStateProvider when auto mode starts + // (not on initial auth, since heartbeat is only for auto mode) + } } else if (reason == 'disconnect') { - // Clear session on disconnect - _clearSession(); + // Only clear shared session when no explicit sessionId was provided + // (explicit sessionId means caller manages its own session lifecycle) + if (sessionId == null) { + _clearSession(); + } } return data; @@ -385,8 +604,12 @@ class ApiService { /// Matches submitWardriveData() in wardrive.js /// /// @param entries List of wardrive entries (TX/RX) - /// @returns Map with success, expires_at, reason, message - Future?> submitWardriveData(List> entries) async { + /// @returns Map with success, expires_at, reason, message, or null when the + /// server answered with something unusable + /// @throws when the request never reached the server, so the caller can tell + /// that apart from a rejection and hold the data without spending a retry + Future?> submitWardriveData( + List> entries) async { if (_sessionId == null) { throw Exception('Cannot submit: no session_id'); } @@ -399,32 +622,37 @@ class ApiService { 'data': entries, }; - final response = await _client.post( - Uri.parse(wardriveEndpoint), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + Uri.parse(wardriveEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); stopwatch.stop(); if (response.statusCode != 200) { - debugError('[API] /wardrive-api.php/wardrive returned HTTP ${response.statusCode}'); - debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + debugError( + '[API] /wardrive-api.php/wardrive returned HTTP ${response.statusCode}'); + debugError( + '[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); } Map data; try { data = json.decode(response.body) as Map; } on FormatException { - debugError('[API] Non-JSON response from /wardrive (HTTP ${response.statusCode}): ' + debugError( + '[API] Non-JSON response from /wardrive (HTTP ${response.statusCode}): ' '${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); rethrow; } // Log with data summary including external_antenna values - final antennaSummary = entries.map((e) => - '${e['type']}:external_antenna=${e['external_antenna']}' - ).join(', '); + final antennaSummary = entries + .map((e) => '${e['type']}:external_antenna=${e['external_antenna']}') + .join(', '); _logApiCall( endpoint: '/wardrive-api.php/wardrive', method: 'POST', @@ -444,6 +672,9 @@ class ApiService { } catch (e) { stopwatch.stop(); debugError('[API] POST /wardrive-api.php/wardrive failed: $e'); + // Let uploadBatch tell the two failures apart. Returning null for both + // is what let a dead socket spend a retry (#437). + if (_requestNeverReachedServer(e)) throw _RequestNeverReachedServer(e); return null; } } @@ -475,24 +706,29 @@ class ApiService { }; } - final response = await _client.post( - Uri.parse(wardriveEndpoint), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + Uri.parse(wardriveEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); stopwatch.stop(); if (response.statusCode != 200) { - debugError('[API] /wardrive-api.php/wardrive (heartbeat) returned HTTP ${response.statusCode}'); - debugError('[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + debugError( + '[API] /wardrive-api.php/wardrive (heartbeat) returned HTTP ${response.statusCode}'); + debugError( + '[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); } Map data; try { data = json.decode(response.body) as Map; } on FormatException { - debugError('[API] Non-JSON response from /wardrive heartbeat (HTTP ${response.statusCode}): ' + debugError( + '[API] Non-JSON response from /wardrive heartbeat (HTTP ${response.statusCode}): ' '${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); rethrow; } @@ -522,7 +758,8 @@ class ApiService { return data; } catch (e) { stopwatch.stop(); - debugError('[API] POST /wardrive-api.php/wardrive (heartbeat) failed: $e'); + debugError( + '[API] POST /wardrive-api.php/wardrive (heartbeat) failed: $e'); return null; } } @@ -536,7 +773,11 @@ class ApiService { }) async { if (_sessionId == null) { debugWarn('[SESSION] No session to validate'); - return (isValid: false, reason: 'no_session', message: 'No active session'); + return ( + isValid: false, + reason: 'no_session', + message: 'No active session' + ); } debugLog('[SESSION] Checking session validity via heartbeat...'); @@ -544,11 +785,16 @@ class ApiService { if (result == null) { debugWarn('[SESSION] Session check failed: no response'); - return (isValid: false, reason: 'no_response', message: 'Server did not respond'); + return ( + isValid: false, + reason: 'no_response', + message: 'Server did not respond' + ); } if (result['success'] == true) { - debugLog('[SESSION] Session is valid (expires_at: ${result['expires_at']})'); + debugLog( + '[SESSION] Session is valid (expires_at: ${result['expires_at']})'); return (isValid: true, reason: null, message: null); } @@ -559,13 +805,24 @@ class ApiService { // Trigger session error callback for critical errors const criticalErrors = { - 'session_expired', 'session_invalid', 'session_revoked', 'bad_session', - 'invalid_key', 'unauthorized', 'bad_key', - 'outside_zone', 'zone_full', 'zone_disabled', + 'session_expired', + 'session_invalid', + 'session_revoked', + 'bad_session', + 'invalid_key', + 'unauthorized', + 'bad_key', + 'zone_full', + 'zone_disabled', }; if (criticalErrors.contains(reason)) { _clearSession(); - onSessionError?.call(reason, message); + await onSessionError?.call(reason, message); + } + + // outside_zone: notify listener but preserve session (backend auto-transfers on zone re-entry) + if (reason == 'outside_zone') { + await onSessionError?.call(reason, message); } return (isValid: false, reason: reason, message: message); @@ -590,6 +847,9 @@ class ApiService { _gpsProvider = null; _heartbeatTimer?.cancel(); _heartbeatTimer = null; + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = null; + _heartbeatRetryCount = 0; debugLog('[HEARTBEAT] Heartbeat mode disabled'); } @@ -597,44 +857,120 @@ class ApiService { /// Matches scheduleHeartbeat() in wardrive.js /// @param expiresAt Unix timestamp when session expires void scheduleHeartbeat(int expiresAt) { - // Cancel any existing heartbeat timer + // Cancel any existing heartbeat timer and reset retry state _heartbeatTimer?.cancel(); _heartbeatTimer = null; + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = null; + _heartbeatRetryCount = 0; if (!_heartbeatEnabled) return; // Calculate when to send heartbeat (1 minute before expiry) final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; final secondsUntilExpiry = expiresAt - now; - final secondsUntilHeartbeat = secondsUntilExpiry - heartbeatBuffer.inSeconds; - - if (secondsUntilHeartbeat <= 0) { + final secondsUntilHeartbeat = + secondsUntilExpiry - heartbeatBuffer.inSeconds; + + // Pacing floor (see minHeartbeatSpacing): a heartbeat may go out at most + // once per floor interval, no matter how expired the expiry reads. This + // is what stops a fast device clock from turning every success response + // into the trigger for the next send. + final lastSent = _lastHeartbeatSentAt; + final floorSeconds = lastSent == null + ? 0 + : minHeartbeatSpacing.inSeconds - + DateTime.now().difference(lastSent).inSeconds; + final delaySeconds = max(secondsUntilHeartbeat, max(floorSeconds, 0)); + + if (delaySeconds <= 0) { // Session is about to expire or already expired - send heartbeat immediately - debugWarn('[HEARTBEAT] Session expires in ${secondsUntilExpiry}s, sending immediately'); + debugWarn( + '[HEARTBEAT] Session expires in ${secondsUntilExpiry}s, sending immediately'); _sendScheduledHeartbeat(); - return; - } - - debugLog('[HEARTBEAT] Scheduling in ${secondsUntilHeartbeat}s (session expires in ${secondsUntilExpiry}s)'); + } else if (secondsUntilHeartbeat <= 0) { + debugWarn( + '[HEARTBEAT] Session expires in ${secondsUntilExpiry}s but a heartbeat ' + 'just went out, next in ${delaySeconds}s'); + _heartbeatTimer = Timer(Duration(seconds: delaySeconds), () { + debugLog('[HEARTBEAT] Timer fired, sending keepalive'); + _sendScheduledHeartbeat(); + }); + } else { + debugLog( + '[HEARTBEAT] Scheduling in ${secondsUntilHeartbeat}s (session expires in ${secondsUntilExpiry}s)'); - _heartbeatTimer = Timer(Duration(seconds: secondsUntilHeartbeat), () { - debugLog('[HEARTBEAT] Timer fired, sending keepalive'); - _sendScheduledHeartbeat(); - }); + _heartbeatTimer = Timer(Duration(seconds: secondsUntilHeartbeat), () { + debugLog('[HEARTBEAT] Timer fired, sending keepalive'); + _sendScheduledHeartbeat(); + }); + } } /// Send scheduled heartbeat with GPS coordinates Future _sendScheduledHeartbeat() async { - // Get GPS coordinates from provider (matching wardrive.js behavior) - final coords = _gpsProvider?.call(); - final result = await sendHeartbeat(lat: coords?.lat, lon: coords?.lon); + // One send in flight at a time. scheduleHeartbeat() cancels pending + // timers but cannot cancel an already-awaiting send, so without this + // guard every re-entrant caller (upload success, per-ping session check) + // stacked another concurrent send chain that never died. Skipping is + // safe: the in-flight send's own response schedules the next heartbeat. + if (_heartbeatInFlight) { + debugLog('[HEARTBEAT] Send already in flight, skipping duplicate'); + return; + } + + // Circuit breaker (see maxHeartbeatsPerMinute): if the lane somehow still + // runs hot, pause it for a minute rather than storm the server. + final now = DateTime.now(); + final windowStart = _heartbeatWindowStart; + if (windowStart == null || + now.difference(windowStart) >= const Duration(minutes: 1)) { + _heartbeatWindowStart = now; + _heartbeatWindowCount = 0; + } + if (_heartbeatWindowCount >= maxHeartbeatsPerMinute) { + debugError( + '[HEARTBEAT] Circuit breaker tripped: $maxHeartbeatsPerMinute sends ' + 'inside a minute, pausing heartbeats for 60s'); + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = + Timer(const Duration(seconds: 60), _sendScheduledHeartbeat); + return; + } + _heartbeatWindowCount++; + _heartbeatInFlight = true; + _lastHeartbeatSentAt = now; + + Map? result; + try { + // Get GPS coordinates from provider (matching wardrive.js behavior) + final coords = _gpsProvider?.call(); + result = await sendHeartbeat(lat: coords?.lat, lon: coords?.lon); + } finally { + _heartbeatInFlight = false; + } if (result?['success'] == true) { debugLog('[HEARTBEAT] Heartbeat successful'); + // Reset retry state on success + _heartbeatRetryCount = 0; + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = null; // Next heartbeat will be scheduled when we get new expires_at } else if (result == null) { - // Network error — transient, trigger session expiring - debugWarn('[HEARTBEAT] Heartbeat failed: network error'); + // Network error — schedule retry with exponential backoff + if (_heartbeatRetryCount < _maxHeartbeatRetries) { + final delay = min(30 * pow(2, _heartbeatRetryCount).toInt(), 120); + _heartbeatRetryCount++; + debugWarn( + '[HEARTBEAT] Network error, scheduling retry $_heartbeatRetryCount/$_maxHeartbeatRetries in ${delay}s'); + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = + Timer(Duration(seconds: delay), _sendScheduledHeartbeat); + } else { + debugError( + '[HEARTBEAT] Network error, all $_maxHeartbeatRetries retries exhausted'); + } _onSessionExpiring?.call(); } else { // Server returned an error — check if critical @@ -643,23 +979,34 @@ class ApiService { debugWarn('[HEARTBEAT] Heartbeat failed: $reason - $message'); const criticalErrors = { - 'session_expired', 'session_invalid', 'session_revoked', 'bad_session', - 'invalid_key', 'unauthorized', 'bad_key', - 'outside_zone', 'zone_full', 'zone_disabled', + 'session_expired', + 'session_invalid', + 'session_revoked', + 'bad_session', + 'invalid_key', + 'unauthorized', + 'bad_key', + 'zone_full', + 'zone_disabled', }; if (criticalErrors.contains(reason)) { _clearSession(); - onSessionError?.call(reason, message); + await onSessionError?.call(reason, message); + } else if (reason == 'outside_zone') { + // Preserve session — backend auto-transfers on zone re-entry + await onSessionError?.call(reason, message); } else { _onSessionExpiring?.call(); } } } - /// Clear session data and cancel heartbeat timer + /// Clear session data and cancel all timers void _clearSession() { _sessionId = null; + _wireKey = null; + _pingCounter = 0; _txAllowed = false; _rxAllowed = false; _sessionExpiresAt = null; @@ -667,10 +1014,14 @@ class ApiService { _scopes = []; _enforceHybrid = false; _enforceDiscDrop = false; + _floodDisabled = false; _minModeInterval = 15; _apiHopBytes = 1; _heartbeatTimer?.cancel(); _heartbeatTimer = null; + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = null; + _heartbeatRetryCount = 0; debugLog('[API] Session cleared'); } @@ -679,11 +1030,65 @@ class ApiService { /// Callback for session errors (session_expired, bad_session, outside_zone) /// Set by AppStateProvider to handle auto-disconnect - Future Function(String? reason, String? message)? onSessionError; + /// + /// [subReason] carries the server's `sub_reason` where it narrows the reason + /// enough to change what the app does. Only the upload path passes it today: + /// a stationary revoke can only be raised by an upload, never a heartbeat. + Future Function(String? reason, String? message, {String? subReason})? + onSessionError; /// Callback for maintenance mode detection (while connected) void Function(String message, String? url)? onMaintenanceMode; + /// Fired when /auth returns a session id different from the one we held. + /// Wired to ApiQueueService.dropStaleTaggedItems(). See that method for why. + void Function()? onSessionIdChanged; + + /// Force-rebuild one vector coverage tile on the region server + /// (`vector_tile.php?...&fresh=1`, see VECTOR_TILES.md). Used by the + /// post-wardrive live refresh: it keeps the server cache hot AND hands the + /// fresh tile bytes back so the caller can patch the user's own cells onto + /// the map without touching the rest of the overlay. + /// + /// `changed`: true/false from the X-Tile-Changed header; null on network + /// failure, non-2xx, or a server that doesn't implement fresh=1 yet. + /// `body`: the uncompressed MVT bytes on a 200, null otherwise (204 = tile + /// is empty; package:http has already gunzipped the response). + Future<({bool? changed, Uint8List? body})> freshenVectorTile({ + required String zone, + required int z, + required int x, + required int y, + int gsize = 300, + }) async { + final url = + Uri.parse('https://${zone.toLowerCase()}.meshmapper.net/vector_tile.php' + '?z=$z&x=$x&y=$y&gsize=$gsize&fresh=1'); + final sw = Stopwatch()..start(); + debugLog( + '[API] GET /vector_tile.php?z=$z&x=$x&y=$y&gsize=$gsize&fresh=1 (zone ${zone.toLowerCase()})'); + try { + final response = + await _client.get(url).timeout(const Duration(seconds: 8)); + final changed = response.headers['x-tile-changed']; + debugLog( + '[API] Tile $z/$x/$y response (${response.statusCode}) in ${(sw.elapsedMilliseconds / 1000).toStringAsFixed(2)}s: ' + '${response.bodyBytes.length}B, X-Tile-Changed=${changed ?? 'absent'}'); + if (response.statusCode != 200 && response.statusCode != 204) { + return (changed: null, body: null); + } + final body = response.statusCode == 200 ? response.bodyBytes : null; + return ( + changed: changed == null ? null : changed == '1', + body: body, + ); + } catch (e) { + debugWarn( + '[API] Tile $z/$x/$y fresh fetch failed in ${(sw.elapsedMilliseconds / 1000).toStringAsFixed(2)}s: $e'); + return (changed: null, body: null); + } + } + /// Upload batch of wardrive data /// Returns UploadResult indicating success, retryable failure, or non-retryable failure /// Triggers onSessionError callback for session-related errors @@ -718,29 +1123,53 @@ class ApiService { // Auth errors 'invalid_key', 'unauthorized', 'bad_key', // Zone errors - 'outside_zone', 'zone_full', 'zone_disabled', + 'zone_full', 'zone_disabled', }; if (criticalErrors.contains(reason)) { - debugError('[API] Upload batch session error: $reason'); + final subReason = result['sub_reason'] as String?; + debugError('[API] Upload batch session error: $reason' + '${subReason != null ? ' ($subReason)' : ''}'); final message = result['message'] as String?; // Clear session locally since it's invalid on server _clearSession(); - // Notify listener for auto-disconnect - onSessionError?.call(reason, message); + // Notify listener for auto-disconnect. MUST be awaited: the handler + // snapshots the queue to offline storage, and returning nonRetryable + // makes the caller DISCARD that same batch. Fired bare, the snapshot's + // first real suspension (a non-empty RX buffer flushing to Hive) let + // the discard land first, so the preserved pings came back short. + await onSessionError?.call(reason, message, subReason: subReason); + return UploadResult.nonRetryable; + } + + // outside_zone: preserve session (backend auto-transfers on zone re-entry), + // but discard this batch (gap-GPS coords would be rejected again) + if (reason == 'outside_zone') { + debugWarn( + '[API] Upload batch outside_zone — discarding batch, preserving session'); + final message = result['message'] as String?; + await onSessionError?.call(reason, message); return UploadResult.nonRetryable; } // Errors where the batch data itself is invalid — retrying won't help const nonRetryableErrors = { - 'gps_inaccurate', 'gps_stale', 'invalid_request', 'zone_disabled', 'outofdate', + 'gps_inaccurate', + 'gps_stale', + 'invalid_request', + 'zone_disabled', + 'outofdate', }; if (nonRetryableErrors.contains(reason)) { - debugWarn('[API] Upload batch non-retryable error: $reason - discarding batch'); + debugWarn( + '[API] Upload batch non-retryable error: $reason - discarding batch'); return UploadResult.nonRetryable; } return UploadResult.retryable; + } on _RequestNeverReachedServer catch (e) { + debugWarn('[API] Upload batch could not reach the server: ${e.cause}'); + return UploadResult.unreachable; } catch (e) { debugError('[API] Upload batch exception: $e'); return UploadResult.retryable; @@ -755,9 +1184,11 @@ class ApiService { try { final url = 'https://${iata.toLowerCase()}.meshmapper.net$endpoint'; - final response = await _client.get( - Uri.parse(url), - ).timeout(const Duration(seconds: 15)); + final response = await _client + .get( + Uri.parse(url), + ) + .timeout(const Duration(seconds: 15)); stopwatch.stop(); @@ -772,7 +1203,8 @@ class ApiService { return []; } - final List jsonList = json.decode(response.body) as List; + final List jsonList = + json.decode(response.body) as List; final repeaters = []; for (final item in jsonList) { @@ -803,10 +1235,254 @@ class ApiService { } } + /// Fetch raw coverage points for a clicked map cell, for the tap-to-inspect + /// GRID SUMMARY. Posts to the region's app-facing endpoint + /// (`app_coverage.php` → `api.php` `map_data`); the app aggregates the points + /// client-side (see `coverage_summary.dart`). Returns `[]` on any failure. + /// + /// NOTE: unlike [fetchRepeaterCoverage] this still collapses a failed request + /// into `[]`. The cell summary has no "couldn't load" state yet + /// (MeshMapper_Server#109 covers the repeater sheet only) and its tap flow + /// chains straight into `filterWithinBlob`. Give the cell sheet that state + /// before propagating the null here. + Future>> fetchMapData({ + required String zone, + required double lat, + required double lon, + required double radiusMeters, + }) async { + final points = await _fetchCoveragePoints( + zone: zone, + label: 'map_data', + body: { + 'request': 'map_data', + 'lat': lat, + 'lon': lon, + 'radius': radiusMeters, + }, + ); + return points ?? const >[]; + } + + /// Fetch the coverage points referencing a repeater (a hex-prefix superset), + /// for the repeater detail sheet's BIDIR/TX/RX/DISC/DEAD totals + max range. + /// Posts to `app_coverage.php` → `api.php` `repeater_coverage`. + /// + /// Returns `null` when the request could not be answered, and `[]` when the + /// zone genuinely has nothing for this prefix. The sheet renders those + /// differently (MeshMapper_Server#109). + Future>?> fetchRepeaterCoverage({ + required String zone, + required String prefix, + }) { + return _fetchCoveragePoints( + zone: zone, + label: 'repeater_coverage', + body: { + 'request': 'repeater_coverage', + 'prefix': prefix, + }, + ); + } + + /// Shared POST to `https://.meshmapper.net/app_coverage.php` with the app + /// key in the JSON body. + /// + /// Returns the point maps on success, which may legitimately be an EMPTY list + /// meaning the server has no coverage for this query, or `null` when the + /// request could not be answered at all. Callers MUST keep those apart: + /// collapsing them into `[]` was MeshMapper_Server#109, where a failed fetch + /// rendered as a repeater that had heard nothing. + Future>?> _fetchCoveragePoints({ + required String zone, + required String label, + required Map body, + }) async { + final z = zone.toLowerCase(); + final url = Uri.parse('https://$z.meshmapper.net/app_coverage.php'); + final sw = Stopwatch()..start(); + debugLog('[COVERAGE] POST /app_coverage.php ($label, zone $z)'); + try { + final response = await _client + .post( + url, + headers: {'Content-Type': 'application/json'}, + body: json.encode({'key': apiKey, ...body}), + ) + .timeout(const Duration(seconds: 15)); + final secs = (sw.elapsedMilliseconds / 1000).toStringAsFixed(2); + + if (response.statusCode != 200) { + final snippet = response.body.isEmpty + ? '(empty)' + : (response.body.length > 200 + ? response.body.substring(0, 200) + : response.body); + debugWarn( + '[COVERAGE] $label HTTP ${response.statusCode} in ${secs}s: $snippet'); + return null; + } + + final decoded = json.decode(response.body); + if (decoded is! List) { + debugWarn('[COVERAGE] $label: unexpected response (not a JSON list)'); + return null; + } + final points = decoded + .whereType() + .map((e) => Map.from(e)) + .toList(); + debugLog('[COVERAGE] $label OK in ${secs}s: ${points.length} points'); + return points; + } catch (e) { + debugWarn( + '[COVERAGE] $label POST failed in ${(sw.elapsedMilliseconds / 1000).toStringAsFixed(2)}s: $e'); + return null; + } + } + + /// Submit wardrive data using an explicit session ID (for offline uploads) + /// Does NOT read/write shared _sessionId, _sessionExpiresAt, or heartbeat state + Future?> submitWardriveDataWithSessionId( + List> entries, + String sessionId, + ) async { + final stopwatch = Stopwatch()..start(); + try { + final payload = { + 'key': apiKey, + 'session_id': sessionId, + 'data': entries, + }; + + final response = await _client + .post( + Uri.parse(wardriveEndpoint), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); + + stopwatch.stop(); + + if (response.statusCode != 200) { + debugError( + '[API] /wardrive-api.php/wardrive (offline) returned HTTP ${response.statusCode}'); + debugError( + '[API] Response body: ${response.body.isEmpty ? '(empty)' : response.body}'); + } + + Map data; + try { + data = json.decode(response.body) as Map; + } on FormatException { + debugError( + '[API] Non-JSON response from /wardrive offline (HTTP ${response.statusCode}): ' + '${response.body.length > 500 ? response.body.substring(0, 500) : response.body}'); + rethrow; + } + + final antennaSummary = entries + .map((e) => '${e['type']}:external_antenna=${e['external_antenna']}') + .join(', '); + _logApiCall( + endpoint: '/wardrive-api.php/wardrive (offline)', + method: 'POST', + stopwatch: stopwatch, + statusCode: response.statusCode, + request: {'data': '${entries.length} items', 'items': antennaSummary}, + response: data, + ); + + // Do NOT update shared _sessionExpiresAt or schedule heartbeat + return data; + } catch (e) { + stopwatch.stop(); + debugError('[API] POST /wardrive-api.php/wardrive (offline) failed: $e'); + return null; + } + } + + /// Upload batch using explicit session ID (for offline uploads) + /// Returns UploadResult only — does NOT call _clearSession(), onSessionError, or onMaintenanceMode + Future uploadBatchWithSessionId( + List> pings, + String sessionId, { + void Function(Map response)? onResponse, + }) async { + if (pings.isEmpty) return UploadResult.success; + + try { + final result = await submitWardriveDataWithSessionId(pings, sessionId); + + if (result == null) { + debugError('[API] Offline upload batch failed: no response'); + return UploadResult.retryable; + } + + if (result['success'] == true) { + // Surface the server's per-batch placement_counts / too_far_region + // (offline routing) so the caller can accumulate an upload summary. + onResponse?.call(result); + debugLog('[API] Offline upload batch SUCCESS: ${pings.length} items'); + return UploadResult.success; + } + + final reason = result['reason'] as String?; + + // Session timing errors: session not yet propagated or expired. + // The pings are fine — retrying with a delay may succeed. + const sessionTimingErrors = { + 'bad_session', + 'session_expired', + 'session_invalid', + 'session_revoked', + }; + if (sessionTimingErrors.contains(reason)) { + debugError('[API] Offline upload batch session timing error: $reason'); + return UploadResult.sessionError; + } + + // Permanent auth/zone errors: session will never work, abort upload. + const permanentSessionErrors = { + 'invalid_key', + 'unauthorized', + 'bad_key', + 'outside_zone', + 'zone_full', + 'zone_disabled', + }; + if (permanentSessionErrors.contains(reason)) { + debugError('[API] Offline upload batch permanent error: $reason'); + return UploadResult.nonRetryable; + } + + const nonRetryableErrors = { + 'gps_inaccurate', + 'gps_stale', + 'invalid_request', + 'zone_disabled', + 'outofdate', + }; + if (nonRetryableErrors.contains(reason)) { + debugWarn( + '[API] Offline upload batch non-retryable error: $reason - stopping upload, pings preserved'); + return UploadResult.nonRetryable; + } + + return UploadResult.retryable; + } catch (e) { + debugError('[API] Offline upload batch exception: $e'); + return UploadResult.retryable; + } + } + /// Dispose of resources void dispose() { _heartbeatTimer?.cancel(); _heartbeatTimer = null; + _heartbeatRetryTimer?.cancel(); + _heartbeatRetryTimer = null; _client.close(); } } diff --git a/lib/services/app_intents/app_intent_bridge_service.dart b/lib/services/app_intents/app_intent_bridge_service.dart new file mode 100644 index 00000000..1a413b8b --- /dev/null +++ b/lib/services/app_intents/app_intent_bridge_service.dart @@ -0,0 +1,185 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../../utils/debug_logger_io.dart'; +import '../external_commands/external_command_models.dart'; +import '../external_surfaces/external_surface_publisher.dart'; +import 'app_intent_commands.dart'; + +typedef AppIntentCommandHandler = Future Function( + AppIntentCommand command, +); +typedef AppIntentSnapshotBuilder = Map? Function(); +typedef AppIntentPreflightKeyBuilder = String Function(); + +/// Independent Flutter bridge for Siri/App Intents. +/// +/// It deliberately has no WatchConnectivity, widget, or CarPlay concepts. +/// Native reads are served from the Foundation-only App Group snapshot; only +/// App Intent mutations cross this channel into Dart. Future native surfaces +/// can consume that bounded projection while retaining their own lifecycle and +/// command transport. +class AppIntentBridgeService { + AppIntentBridgeService({ + @visibleForTesting MethodChannel? channel, + @visibleForTesting Duration debounceDelay = defaultDebounceDelay, + @visibleForTesting + Duration minimumNonUrgentInterval = defaultMinimumNonUrgentInterval, + }) : _channel = channel ?? const MethodChannel(_channelName) { + _publisher = + ExternalSurfacePublisher, Map>( + debounceDelay: debounceDelay, + minimumNonUrgentInterval: minimumNonUrgentInterval, + isEnabled: () => isSupportedPlatform, + preflightPolicy: ExternalSurfacePreflightPolicy.deduplicateAfterPublish, + payloadBuilder: (snapshot) => snapshot, + fingerprintBuilder: (snapshot) { + final semantic = Map.from(snapshot) + ..remove('updatedAtMs'); + return jsonEncode(semantic); + }, + urgencyKeyBuilder: (_) => null, + publish: (publication) async => await publishSnapshot(publication.payload) + ? const ExternalSurfacePublishResult.published() + : const ExternalSurfacePublishResult.rejected(), + restartDebounce: false, + immediateBypassesMinimumInterval: true, + ); + } + + static const String _channelName = 'meshmapper/app_intents'; + static const int _commandQueueDepth = 8; + static const int _maximumRememberedCommands = 64; + static const Duration defaultDebounceDelay = Duration(milliseconds: 200); + static const Duration defaultMinimumNonUrgentInterval = Duration(seconds: 2); + + static void reserveCommandQueue() { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS) return; + ServicesBinding.instance.channelBuffers + .resize(_channelName, _commandQueueDepth); + } + + final MethodChannel _channel; + late final ExternalSurfacePublisher, + Map> _publisher; + AppIntentCommandHandler? _commandHandler; + final Map _commandOutcomes = {}; + final Map> _inFlightCommands = {}; + bool _disposed = false; + + bool get isSupportedPlatform => + !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; + + void attachCommandHandler(AppIntentCommandHandler handler) { + _commandHandler = handler; + if (!isSupportedPlatform) return; + _channel.setMethodCallHandler(_handleNativeCall); + } + + Future _handleNativeCall(MethodCall call) async { + if (call.method != 'command') return null; + final arguments = call.arguments; + if (arguments is! Map) { + return _refusal('Malformed command').toMap(); + } + final command = AppIntentCommand.fromMap(arguments); + if (command == null) { + return _refusal('Malformed command').toMap(); + } + + final previous = _commandOutcomes[command.id]; + if (previous != null) return previous.toMap(commandId: command.id); + final inFlight = _inFlightCommands[command.id]; + if (inFlight != null) { + final completion = await inFlight; + return completion.toMap(commandId: command.id); + } + + final handler = _commandHandler; + if (handler == null) { + return _refusal('App not ready').toMap(commandId: command.id); + } + + // Normalize handler failures inside the shared future so every concurrent + // delivery of this UUID receives the same refusal instead of the owner + // catching an error while a duplicate sees a PlatformException. + final execution = _executeCommand(handler, command); + _inFlightCommands[command.id] = execution; + try { + final completion = await execution; + _remember(command.id, completion); + return completion.toMap(commandId: command.id); + } finally { + if (identical(_inFlightCommands[command.id], execution)) { + _inFlightCommands.remove(command.id); + } + } + } + + Future _executeCommand( + AppIntentCommandHandler handler, + AppIntentCommand command, + ) async { + try { + return await handler(command); + } catch (error) { + debugError('[SIRI] ${command.kind.name} failed: $error'); + return _refusal('Command failed'); + } + } + + Future publishSnapshot(Map snapshot) async { + if (!isSupportedPlatform || _disposed) return false; + try { + await _channel.invokeMethod('publishSnapshot', snapshot); + return true; + } on MissingPluginException { + return false; + } on PlatformException catch (error) { + debugError( + '[SIRI] Snapshot publish failed: ${error.code}: ${error.message}', + ); + return false; + } + } + + void schedule( + AppIntentSnapshotBuilder builder, { + required AppIntentPreflightKeyBuilder preflightKeyBuilder, + bool immediate = false, + }) { + if (!isSupportedPlatform || _disposed) return; + _publisher.schedule( + builder, + preflightKeyBuilder: preflightKeyBuilder, + immediate: immediate, + ); + } + + void _remember(String id, ExternalCommandCompletion completion) { + _commandOutcomes[id] = completion; + while (_commandOutcomes.length > _maximumRememberedCommands) { + _commandOutcomes.remove(_commandOutcomes.keys.first); + } + } + + static ExternalCommandCompletion _refusal(String message) => + ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: ExternalCommandReason.other(message), + ); + + void dispose() { + if (_disposed) return; + _disposed = true; + _commandHandler = null; + _commandOutcomes.clear(); + _inFlightCommands.clear(); + _publisher.dispose(); + if (isSupportedPlatform) _channel.setMethodCallHandler(null); + } +} diff --git a/lib/services/app_intents/app_intent_commands.dart b/lib/services/app_intents/app_intent_commands.dart new file mode 100644 index 00000000..407796e5 --- /dev/null +++ b/lib/services/app_intents/app_intent_commands.dart @@ -0,0 +1,101 @@ +import '../external_commands/external_command_models.dart'; + +enum AppIntentCommandKind { + startSession, + stopSession, + manualPing, + connectLastCompanion; + + static AppIntentCommandKind? fromWire(String value) { + for (final kind in values) { + if (kind.name == value) return kind; + } + return null; + } + + ExternalSessionCommandKind? get sessionKind => switch (this) { + AppIntentCommandKind.startSession => + ExternalSessionCommandKind.startSession, + AppIntentCommandKind.stopSession => + ExternalSessionCommandKind.stopSession, + AppIntentCommandKind.manualPing => + ExternalSessionCommandKind.manualPing, + AppIntentCommandKind.connectLastCompanion => null, + }; +} + +/// A mutation arriving from the iOS App Intents transport. +/// +/// Session mutations convert to [ExternalSessionCommand] so Siri and Watch +/// retain one policy path. Connecting is intentionally Siri-only and remains +/// outside that session model. +class AppIntentCommand { + const AppIntentCommand({ + required this.id, + required this.kind, + required this.issuedAt, + this.expiresAt, + this.mode, + this.sessionId, + }); + + final String id; + final AppIntentCommandKind kind; + final DateTime issuedAt; + + /// When the App Intent stops waiting and reports failure to the person. + /// + /// Older native builds omit it; the shared age rule still bounds those. + final DateTime? expiresAt; + final String? mode; + final String? sessionId; + + static AppIntentCommand? fromMap(Map map) { + final id = map['id']; + final source = map['source']; + final kind = map['kind']; + final issuedAtMs = map['issuedAtMs']; + if (id is! String || + id.isEmpty || + source != ExternalCommandSource.siri.name || + kind is! String || + issuedAtMs is! num) { + return null; + } + final parsedKind = AppIntentCommandKind.fromWire(kind); + if (parsedKind == null) return null; + final mode = map['mode']; + final sessionId = map['sessionId']; + final expiresAtMs = map['expiresAtMs']; + if (mode != null && mode is! String) return null; + if (sessionId != null && sessionId is! String) return null; + if (expiresAtMs != null && expiresAtMs is! num) return null; + + return AppIntentCommand( + id: id, + kind: parsedKind, + issuedAt: DateTime.fromMillisecondsSinceEpoch(issuedAtMs.toInt()), + expiresAt: expiresAtMs == null + ? null + : DateTime.fromMillisecondsSinceEpoch( + (expiresAtMs as num).toInt(), + ), + mode: mode as String?, + sessionId: sessionId as String?, + ); + } + + ExternalSessionCommand? toExternalSessionCommand() { + final sessionKind = kind.sessionKind; + if (sessionKind == null) return null; + return ExternalSessionCommand( + id: id, + source: ExternalCommandSource.siri, + kind: sessionKind, + issuedAt: issuedAt, + expiresAt: expiresAt, + mode: mode, + sessionId: sessionId, + ); + } +} diff --git a/lib/services/app_intents/last_companion_connection.dart b/lib/services/app_intents/last_companion_connection.dart new file mode 100644 index 00000000..097762ce --- /dev/null +++ b/lib/services/app_intents/last_companion_connection.dart @@ -0,0 +1,62 @@ +import '../external_commands/external_command_models.dart'; +import '../external_commands/external_session_commands.dart'; +import 'app_intent_commands.dart'; + +ExternalCommandAdmission resolveLastCompanionConnection({ + required AppIntentCommand command, + required bool hasRememberedCompanion, + required bool isConnected, + required bool isConnectedToRememberedCompanion, + required bool isConnecting, + required bool canReconnectWithoutUserInput, + DateTime? now, +}) { + // The deadline comes first: it is the instant the intent actually stops + // waiting, and it is set when the command reaches Dart rather than when it + // was created, so it can differ from the shared age rule in both directions. + final ageRefusal = externalCommandDeadlineRefusal( + command.expiresAt, + now: now, + ) ?? + externalCommandTimestampRefusal( + command.issuedAt, + now: now, + ); + if (ageRefusal != null) { + return ExternalCommandAdmission( + disposition: ExternalCommandDisposition.refused, + reason: ageRefusal, + ); + } + if (!hasRememberedCompanion) { + return const ExternalCommandAdmission( + disposition: ExternalCommandDisposition.refused, + reason: ExternalCommandReason.noRememberedCompanion, + ); + } + if (isConnected) { + return ExternalCommandAdmission( + disposition: isConnectedToRememberedCompanion + ? ExternalCommandDisposition.noOp + : ExternalCommandDisposition.refused, + reason: isConnectedToRememberedCompanion + ? ExternalCommandReason.alreadyConnected + : ExternalCommandReason.anotherCompanionConnected, + ); + } + if (isConnecting) { + return const ExternalCommandAdmission( + disposition: ExternalCommandDisposition.refused, + reason: ExternalCommandReason.alreadyConnecting, + ); + } + if (!canReconnectWithoutUserInput) { + return const ExternalCommandAdmission( + disposition: ExternalCommandDisposition.refused, + reason: ExternalCommandReason.userInteractionRequired, + ); + } + return const ExternalCommandAdmission( + disposition: ExternalCommandDisposition.admitted, + ); +} diff --git a/lib/services/app_intents/siri_snapshot_builder.dart b/lib/services/app_intents/siri_snapshot_builder.dart new file mode 100644 index 00000000..687345d4 --- /dev/null +++ b/lib/services/app_intents/siri_snapshot_builder.dart @@ -0,0 +1,401 @@ +import '../../models/log_entry.dart'; +import '../../models/repeater.dart'; +import '../external_surfaces/geo/external_surface_geo_builder.dart'; +import '../watch/watch_models.dart'; +import 'siri_snapshot_models.dart'; + +class SiriSnapshotBuilder { + const SiriSnapshotBuilder._(); + + static const int maximumObservations = 64; + static const int maximumRepeaters = 64; + static const Duration maximumObservationAge = Duration(hours: 2); + + /// Stable identity a Siri entity is keyed on. + /// + /// The heard entity and the catalogue entity are joined on this string, so + /// both sides must derive it the same way. [Repeater.iata] is nullable, and a + /// repeater without one would otherwise be filed under two different keys and + /// lose its name, zone and coordinates on the heard side. + /// + /// **This string must never depend on session state.** Shortcuts persists a + /// chosen RepeaterEntity by its identifier and resolves it back by exact + /// match, so an id that folded in the live session's zone would flip between + /// connects and zones (`SEA|x` one drive, `GLOBAL|x` the next) and silently + /// break every saved shortcut that named that repeater. + static String repeaterEntityId(Repeater repeater) => + '${repeater.iata ?? 'GLOBAL'}|${repeater.id}'; + + static SiriRecentHeard buildRecentHeard({ + required List txEntries, + required List rxEntries, + required List discoveryEntries, + required List traceEntries, + required List repeaters, + int? hopBytes, + DateTime? now, + }) { + final cutoff = (now ?? DateTime.now()).subtract(maximumObservationAge); + final candidates = <_SiriObservationCandidate>[]; + final repeaterCache = {}; + + // The catalogue scan is the expensive part, and the same repeater is heard + // over and over, so the lookup is memoised on the candidate's raw identity. + Repeater? lookup(_SiriObservationCandidate candidate) { + final cacheKey = _identityKey(candidate); + if (repeaterCache.containsKey(cacheKey)) return repeaterCache[cacheKey]; + final found = (candidate.identity == null + ? null + : ExternalSurfaceGeoBuilder.resolveRepeater( + repeaters: repeaters, + id: candidate.identity!, + hopBytes: hopBytes, + )) ?? + ExternalSurfaceGeoBuilder.resolveRepeater( + repeaters: repeaters, + id: candidate.displayId.toUpperCase(), + hopBytes: hopBytes, + ); + repeaterCache[cacheKey] = found; + return found; + } + + SiriRepeaterObservation resolve(_SiriObservationCandidate candidate) { + final normalizedId = candidate.displayId.toUpperCase(); + final repeater = lookup(candidate); + final resolved = repeater != null; + final hasDistance = resolved && + repeater.hasLocation && + repeater.lat.isFinite && + repeater.lon.isFinite && + candidate.latitude.isFinite && + candidate.longitude.isFinite; + return SiriRepeaterObservation( + entityId: resolved ? repeaterEntityId(repeater) : null, + displayHexId: normalizedId, + name: resolved && repeater.name != 'Unknown' ? repeater.name : null, + observedAt: candidate.observedAt, + kind: candidate.kind, + direct: candidate.direct, + hopCount: candidate.hopCount, + snr: candidate.snr?.isFinite == true ? candidate.snr : null, + rssi: candidate.rssi, + distanceM: hasDistance + ? WatchWire.distanceMeters( + candidate.latitude, + candidate.longitude, + repeater.lat, + repeater.lon, + ) + : null, + repeaterLat: resolved && + repeater.hasLocation && + repeater.lat.isFinite && + repeater.lon.isFinite + ? repeater.lat + : null, + repeaterLon: resolved && + repeater.hasLocation && + repeater.lat.isFinite && + repeater.lon.isFinite + ? repeater.lon + : null, + resolved: resolved, + ); + } + + for (final entry in txEntries) { + if (entry.timestamp.isBefore(cutoff)) continue; + for (final event in entry.events) { + candidates.add(_SiriObservationCandidate( + displayId: event.repeaterId, + observedAt: entry.timestamp, + kind: SiriObservationKind.txEcho, + direct: true, + hopCount: 1, + latitude: entry.latitude, + longitude: entry.longitude, + snr: event.snr, + rssi: event.rssi, + )); + } + for (final event in entry.multiHopEvents) { + candidates.add(_SiriObservationCandidate( + displayId: event.repeaterId, + observedAt: entry.timestamp, + kind: SiriObservationKind.txEcho, + direct: false, + hopCount: event.pathHops.isEmpty ? 2 : event.pathHops.length, + latitude: entry.latitude, + longitude: entry.longitude, + snr: event.snr, + rssi: event.rssi, + )); + } + } + + for (final entry in rxEntries) { + if (entry.timestamp.isBefore(cutoff)) continue; + candidates.add(_SiriObservationCandidate( + displayId: entry.repeaterId, + observedAt: entry.timestamp, + kind: SiriObservationKind.passiveRx, + direct: entry.pathLength <= 1, + hopCount: entry.pathLength, + latitude: entry.latitude, + longitude: entry.longitude, + snr: entry.snr, + rssi: entry.rssi, + )); + } + + for (final entry in discoveryEntries) { + if (entry.timestamp.isBefore(cutoff)) continue; + for (final node in entry.discoveredNodes + .where((node) => node.nodeType == 'REPEATER')) { + candidates.add(_SiriObservationCandidate( + displayId: node.repeaterId, + identity: node.pubkeyHex, + observedAt: entry.timestamp, + kind: SiriObservationKind.discovery, + direct: true, + hopCount: 1, + latitude: entry.latitude, + longitude: entry.longitude, + snr: node.localSnr, + rssi: node.localRssi, + )); + } + } + + for (final entry in traceEntries.where((entry) => entry.success)) { + if (entry.timestamp.isBefore(cutoff)) continue; + candidates.add(_SiriObservationCandidate( + displayId: entry.targetRepeaterId, + identity: entry.targetRepeaterId, + observedAt: entry.timestamp, + kind: SiriObservationKind.trace, + direct: true, + hopCount: 0, + latitude: entry.latitude, + longitude: entry.longitude, + snr: entry.localSnr, + rssi: entry.localRssi, + )); + } + + // One row per distinct raw identity, carrying that repeater's most recent + // sighting. Collapsing the candidates before resolving keeps the catalogue + // scan proportional to the number of repeaters rather than the number of + // observations, and it is what lets the session count see past the + // [maximumObservations] newest events without walking the whole history + // again. + final newestByIdentity = {}; + for (final candidate in candidates) { + final key = _identityKey(candidate); + final existing = newestByIdentity[key]; + if (existing == null || + existing.observedAt.isBefore(candidate.observedAt)) { + newestByIdentity[key] = candidate; + } + } + + // The count only needs an identity and a timestamp, so the distinct roll-up + // stays lightweight: no names, no coordinates and above all no haversine + // per row, none of which any caller of it reads. Resolution comes from the + // same memo the wire list uses, so nothing is looked up twice. + final resolvedHeard = {}; + final unresolvedHeard = {}; + for (final entry in newestByIdentity.entries) { + final repeater = lookup(entry.value); + if (repeater != null) { + resolvedHeard[entry.key] = repeater; + } else { + unresolvedHeard[entry.key] = entry.value; + } + } + + // A repeater met by discovery carries a full public key and resolves; the + // same repeater met by TX echo or passive RX carries only a short path + // hash, which is ambiguous against the whole catalogue and so resolves to + // nothing. Counted raw that is one repeater twice. Re-run the same prefix + // relation against only the repeaters actually heard in this set: that + // narrower field is usually unambiguous, and where it is not the row stays + // on its own rather than being attached to a guess. + final heardRepeaters = {}; + for (final repeater in resolvedHeard.values) { + heardRepeaters[repeater.id] = repeater; + } + final heardList = heardRepeaters.values.toList(growable: false); + + final distinct = [ + for (final entry in resolvedHeard.entries) + SiriHeardIdentity( + key: repeaterEntityId(entry.value), + observedAt: newestByIdentity[entry.key]!.observedAt, + ), + for (final entry in unresolvedHeard.entries) + SiriHeardIdentity( + key: _collapsedKey( + candidate: entry.value, + heardRepeaters: heardList, + hopBytes: hopBytes, + ) ?? + 'unresolved:${entry.value.displayId.toUpperCase()}', + observedAt: entry.value.observedAt, + ), + ]; + + candidates.sort((a, b) => b.observedAt.compareTo(a.observedAt)); + return SiriRecentHeard( + observations: candidates.take(maximumObservations).map(resolve).toList( + growable: false, + ), + distinctHeard: List.unmodifiable(distinct), + ); + } + + /// The entity id of the already-heard repeater this row unambiguously + /// belongs to, or null when nothing in the heard set claims it outright. + static String? _collapsedKey({ + required _SiriObservationCandidate candidate, + required List heardRepeaters, + required int? hopBytes, + }) { + if (heardRepeaters.isEmpty) return null; + final match = (candidate.identity == null + ? null + : ExternalSurfaceGeoBuilder.resolveRepeater( + repeaters: heardRepeaters, + id: candidate.identity!, + hopBytes: hopBytes, + )) ?? + ExternalSurfaceGeoBuilder.resolveRepeater( + repeaters: heardRepeaters, + id: candidate.displayId.toUpperCase(), + hopBytes: hopBytes, + ); + return match == null ? null : repeaterEntityId(match); + } + + /// Counts distinct repeaters heard since [sessionStartedAt]. + /// + /// Feed this [SiriRecentHeard.distinctHeard], not the snapshot's + /// [SiriRecentHeard.observations]: that list is capped at + /// [maximumObservations] for the wire, and counting it would cap the spoken + /// total at the 64 newest events instead of the whole session. + /// + /// [maximumObservationAge] bounds the cache, not a session: a two-hour + /// history routinely spans several sessions, so counting all of it would let + /// a session that has heard nothing report the previous one's repeaters. + /// A null start means no session is running, which counts as none. + static int countUniqueRepeatersHeard( + List heard, + DateTime? sessionStartedAt, + ) { + if (sessionStartedAt == null) return 0; + return heard + .where((item) => !item.observedAt.isBefore(sessionStartedAt)) + .map((item) => item.key) + .toSet() + .length; + } + + static String _identityKey(_SiriObservationCandidate candidate) => + '${candidate.identity?.toUpperCase() ?? ''}|' + '${candidate.displayId.toUpperCase()}'; + + static List buildRepeaterCatalog( + List repeaters, + ) { + final ranked = List.of(repeaters) + ..sort((a, b) { + final active = (b.isActive ? 1 : 0).compareTo(a.isActive ? 1 : 0); + if (active != 0) return active; + final heard = b.lastHeard.compareTo(a.lastHeard); + return heard != 0 ? heard : a.id.compareTo(b.id); + }); + return ranked.take(maximumRepeaters).map( + (repeater) { + final hasFiniteLocation = repeater.hasLocation && + repeater.lat.isFinite && + repeater.lon.isFinite; + return SiriRepeaterEntitySnapshot( + id: repeaterEntityId(repeater), + name: repeater.name, + hexId: repeater.hexId.toUpperCase(), + zoneCode: repeater.iata, + isActive: repeater.isActive, + isNew: repeater.isNew, + serverLastHeard: repeater.lastHeard == 0 + ? null + : DateTime.fromMillisecondsSinceEpoch( + repeater.lastHeard * 1000, + ), + latitude: hasFiniteLocation ? repeater.lat : null, + longitude: hasFiniteLocation ? repeater.lon : null, + ); + }, + ).toList(growable: false); + } +} + +/// The two views of the same heard history the snapshot needs. +/// +/// [observations] is what ships to the watch and to Siri, bounded so the App +/// Group payload stays small. [distinctHeard] is the unbounded roll-up used for +/// counting, so a long session's total is not silently capped at that bound. +class SiriRecentHeard { + const SiriRecentHeard({ + required this.observations, + required this.distinctHeard, + }); + + /// Newest first, capped at [SiriSnapshotBuilder.maximumObservations]. + final List observations; + + /// One entry per distinct repeater, carrying its most recent sighting. + final List distinctHeard; +} + +/// One distinct repeater heard, reduced to what the session count reads. +/// +/// Deliberately not a [SiriRepeaterObservation]: this list is unbounded, it is +/// rebuilt on every snapshot publish, and nothing downstream reads a name, a +/// signal figure or a distance from it. Resolving those (a haversine per row) +/// for a number that only needs distinct identities was pure waste. +class SiriHeardIdentity { + const SiriHeardIdentity({required this.key, required this.observedAt}); + + /// The repeater's entity id, or `unresolved:` when the catalogue and + /// the rest of the heard set both fail to claim the hash. + final String key; + + final DateTime observedAt; +} + +class _SiriObservationCandidate { + const _SiriObservationCandidate({ + required this.displayId, + this.identity, + required this.observedAt, + required this.kind, + required this.direct, + required this.hopCount, + required this.latitude, + required this.longitude, + this.snr, + this.rssi, + }); + + final String displayId; + final String? identity; + final DateTime observedAt; + final SiriObservationKind kind; + final bool direct; + final int hopCount; + final double latitude; + final double longitude; + final double? snr; + final int? rssi; +} diff --git a/lib/services/app_intents/siri_snapshot_models.dart b/lib/services/app_intents/siri_snapshot_models.dart new file mode 100644 index 00000000..01844612 --- /dev/null +++ b/lib/services/app_intents/siri_snapshot_models.dart @@ -0,0 +1,235 @@ +import 'package:flutter/foundation.dart'; + +const int siriSnapshotWireVersion = 1; + +enum SiriObservationKind { txEcho, passiveRx, discovery, trace } + +@immutable +class SiriConnectionSnapshot { + const SiriConnectionSnapshot({ + required this.isConnected, + this.deviceName, + this.batteryPercent, + required this.gpsStatus, + }); + + final bool isConnected; + final String? deviceName; + final int? batteryPercent; + final String gpsStatus; + + Map toMap() => { + 'isConnected': isConnected, + 'deviceName': deviceName, + 'batteryPercent': batteryPercent, + 'gpsStatus': gpsStatus, + }; +} + +@immutable +class SiriSessionSnapshot { + const SiriSessionSnapshot({ + this.id, + this.startedAt, + required this.active, + required this.starting, + required this.mode, + required this.phase, + required this.phaseTitle, + this.phaseDetail, + this.phaseEndsAt, + this.zoneCode, + required this.txCount, + required this.rxCount, + required this.discoveryCount, + required this.traceCount, + required this.queueSize, + required this.uniqueRepeatersHeard, + }); + + final String? id; + + /// The boundary "current session" means. Null when nothing is running. + final DateTime? startedAt; + final bool active; + final bool starting; + final String mode; + final String phase; + final String phaseTitle; + final String? phaseDetail; + final DateTime? phaseEndsAt; + final String? zoneCode; + final int txCount; + final int rxCount; + final int discoveryCount; + final int traceCount; + final int queueSize; + final int uniqueRepeatersHeard; + + Map toMap() => { + 'id': id, + 'startedAtMs': startedAt?.millisecondsSinceEpoch, + 'active': active, + 'starting': starting, + 'mode': mode, + 'phase': phase, + 'phaseTitle': phaseTitle, + 'phaseDetail': phaseDetail, + 'phaseEndsAtMs': phaseEndsAt?.millisecondsSinceEpoch, + 'zoneCode': zoneCode, + 'txCount': txCount, + 'rxCount': rxCount, + 'discoveryCount': discoveryCount, + 'traceCount': traceCount, + 'queueSize': queueSize, + 'uniqueRepeatersHeard': uniqueRepeatersHeard, + }; +} + +@immutable +class SiriControlsSnapshot { + const SiriControlsSnapshot({ + required this.availableStartModes, + required this.canStart, + this.startBlockedReason, + required this.canStop, + required this.canManualPing, + this.manualPingBlockedReason, + this.manualCooldownEndsAt, + }); + + final List availableStartModes; + final bool canStart; + final String? startBlockedReason; + final bool canStop; + final bool canManualPing; + final String? manualPingBlockedReason; + final DateTime? manualCooldownEndsAt; + + Map toMap() => { + 'availableStartModes': availableStartModes, + 'canStart': canStart, + 'startBlockedReason': startBlockedReason, + 'canStop': canStop, + 'canManualPing': canManualPing, + 'manualPingBlockedReason': manualPingBlockedReason, + 'manualCooldownEndsAtMs': manualCooldownEndsAt?.millisecondsSinceEpoch, + }; +} + +@immutable +class SiriRepeaterObservation { + const SiriRepeaterObservation({ + this.entityId, + required this.displayHexId, + this.name, + required this.observedAt, + required this.kind, + required this.direct, + required this.hopCount, + this.snr, + this.rssi, + this.distanceM, + this.repeaterLat, + this.repeaterLon, + required this.resolved, + }); + + final String? entityId; + final String displayHexId; + final String? name; + final DateTime observedAt; + final SiriObservationKind kind; + final bool direct; + final int hopCount; + final double? snr; + final int? rssi; + final double? distanceM; + final double? repeaterLat; + final double? repeaterLon; + final bool resolved; + + Map toMap() => { + 'entityId': entityId, + 'displayHexId': displayHexId, + 'name': name, + 'observedAtMs': observedAt.millisecondsSinceEpoch, + 'kind': kind.name, + 'direct': direct, + 'hopCount': hopCount, + 'snr': snr, + 'rssi': rssi, + 'distanceM': distanceM, + 'repeaterLat': repeaterLat, + 'repeaterLon': repeaterLon, + 'resolved': resolved, + }; +} + +@immutable +class SiriRepeaterEntitySnapshot { + const SiriRepeaterEntitySnapshot({ + required this.id, + required this.name, + required this.hexId, + this.zoneCode, + required this.isActive, + required this.isNew, + this.serverLastHeard, + this.latitude, + this.longitude, + }); + + final String id; + final String name; + final String hexId; + final String? zoneCode; + final bool isActive; + final bool isNew; + final DateTime? serverLastHeard; + final double? latitude; + final double? longitude; + + Map toMap() => { + 'id': id, + 'name': name, + 'hexId': hexId, + 'zoneCode': zoneCode, + 'isActive': isActive, + 'isNew': isNew, + 'serverLastHeardMs': serverLastHeard?.millisecondsSinceEpoch, + 'latitude': latitude, + 'longitude': longitude, + }; +} + +@immutable +class SiriSnapshot { + const SiriSnapshot({ + this.version = siriSnapshotWireVersion, + required this.updatedAt, + required this.connection, + required this.session, + required this.controls, + required this.recentHeard, + required this.repeaters, + }); + + final int version; + final DateTime updatedAt; + final SiriConnectionSnapshot connection; + final SiriSessionSnapshot session; + final SiriControlsSnapshot controls; + final List recentHeard; + final List repeaters; + + Map toMap() => { + 'version': version, + 'updatedAtMs': updatedAt.millisecondsSinceEpoch, + 'connection': connection.toMap(), + 'session': session.toMap(), + 'controls': controls.toMap(), + 'recentHeard': recentHeard.map((item) => item.toMap()).toList(), + 'repeaters': repeaters.map((item) => item.toMap()).toList(), + }; +} diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 41898ca0..d0f0c90a 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -11,11 +11,25 @@ import '../utils/debug_logger_io.dart'; class AudioService { static const String _prefsBoxName = 'audio_preferences'; static const String _enabledKey = 'sound_enabled'; + static const String _txEnabledKey = 'tx_sound_enabled'; + static const String _rxEnabledKey = 'rx_sound_enabled'; + static const String _txAsset = 'assets/transmitted_packet.mp3'; + static const String _rxAsset = 'assets/received_packet.mp3'; + + /// Delay before releasing audio focus after the last sound plays. + /// Prevents rapid activate/deactivate cycles that break Android audio, + /// while still releasing focus for Android Auto ducking. + static const Duration _focusReleaseDelay = Duration(seconds: 3); AudioPlayer? _txPlayer; AudioPlayer? _rxPlayer; bool _initialized = false; bool _enabled = false; // Disabled by default, remembered once user changes it + bool _txEnabled = + true; // TX sound sub-toggle (only matters when master is on) + bool _rxEnabled = + true; // RX sound sub-toggle (only matters when master is on) + Timer? _focusReleaseTimer; /// Whether the audio service is initialized bool get isInitialized => _initialized; @@ -23,6 +37,12 @@ class AudioService { /// Whether sound notifications are enabled bool get isEnabled => _enabled; + /// Whether TX sound is enabled (ping sent / discovery sent) + bool get isTxEnabled => _txEnabled; + + /// Whether RX sound is enabled (repeater echo / RX observation) + bool get isRxEnabled => _rxEnabled; + /// Initialize the audio service and pre-load sounds Future initialize() async { if (_initialized) return; @@ -48,7 +68,9 @@ class AudioService { // Android: transient focus allows other audio to continue androidAudioAttributes: AndroidAudioAttributes( contentType: AndroidAudioContentType.sonification, - usage: AndroidAudioUsage.notification, + // Route blips to the MEDIA stream so they follow the media + // volume slider (not the ringer/notification volume). See #88. + usage: AndroidAudioUsage.media, ), androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransientMayDuck, @@ -66,9 +88,8 @@ class AudioService { _rxPlayer = AudioPlayer(); // Pre-load the audio assets for instant playback - // Using asset:// prefix for just_audio - await _txPlayer!.setAsset('assets/transmitted_packet.mp3'); - await _rxPlayer!.setAsset('assets/received_packet.mp3'); + await _txPlayer!.setAsset(_txAsset); + await _rxPlayer!.setAsset(_rxAsset); _initialized = true; debugLog('[AUDIO] Audio service initialized, enabled=$_enabled'); @@ -92,9 +113,15 @@ class AudioService { } else { debugLog('[AUDIO] No saved preference, using default: $_enabled'); } + + final txEnabled = box.get(_txEnabledKey); + if (txEnabled != null) _txEnabled = txEnabled as bool; + final rxEnabled = box.get(_rxEnabledKey); + if (rxEnabled != null) _rxEnabled = rxEnabled as bool; + debugLog('[AUDIO] Loaded sub-toggles: tx=$_txEnabled, rx=$_rxEnabled'); } catch (e) { debugError('[AUDIO] Failed to load enabled state: $e'); - // Keep default (disabled) + // Keep defaults } } @@ -105,6 +132,7 @@ class AudioService { try { await box.put(_enabledKey, _enabled); + await box.flush(); debugLog('[AUDIO] Saved enabled state: $_enabled'); } catch (e) { debugError('[AUDIO] Failed to save enabled state: $e'); @@ -125,13 +153,15 @@ class AudioService { debugError('[AUDIO] Hive box "$boxName" timed out - attempting recovery'); return _attemptRecovery(boxName, timeout); } catch (e) { - debugError('[AUDIO] Hive box "$boxName" failed: $e - attempting recovery'); + debugError( + '[AUDIO] Hive box "$boxName" failed: $e - attempting recovery'); return _attemptRecovery(boxName, timeout); } } /// Attempt to recover from Hive corruption - Future?> _attemptRecovery(String boxName, Duration timeout) async { + Future?> _attemptRecovery( + String boxName, Duration timeout) async { try { debugLog('[AUDIO] Deleting corrupted box "$boxName"...'); await Hive.deleteBoxFromDisk(boxName); @@ -140,62 +170,88 @@ class AudioService { debugLog('[AUDIO] Box "$boxName" opened after recovery'); return box; } catch (e) { - debugError('[AUDIO] Recovery failed for "$boxName": $e - operating without persistence'); + debugError( + '[AUDIO] Recovery failed for "$boxName": $e - operating without persistence'); return null; } } /// Play the transmit sound (when TX ping or Discovery request is sent) Future playTransmitSound() async { - debugLog('[AUDIO] playTransmitSound called - initialized=$_initialized, enabled=$_enabled'); - if (!_initialized || !_enabled) { - debugLog('[AUDIO] playTransmitSound skipped - not initialized or disabled'); - return; - } + if (!_txEnabled) return; + await _playSound(_txPlayer, _txAsset, 'TX'); + } + + /// Play the receive sound (when repeater echo or RX observation is detected) + Future playReceiveSound() async { + if (!_rxEnabled) return; + await _playSound(_rxPlayer, _rxAsset, 'RX'); + } + + /// Shared playback logic for both TX and RX sounds. + /// Ensures audio session is active before playing and debounces focus release. + Future _playSound( + AudioPlayer? player, String assetPath, String label) async { + if (!_initialized || !_enabled || player == null) return; try { - debugLog('[AUDIO] Playing transmit sound...'); - // Seek to start and play with timeout to prevent indefinite hangs - // (iOS audio session corruption can cause play() to never complete) - await _txPlayer?.seek(Duration.zero); - await _txPlayer?.play().timeout(const Duration(seconds: 3)); - debugLog('[AUDIO] Transmit sound played successfully'); - // Release audio focus after playback completes - // Critical for Android Auto - without this, car audio stays ducked - await _releaseAudioFocus(); + await _ensureSessionActive(); + await player.seek(Duration.zero); + await player.play().timeout(const Duration(seconds: 3)); + debugLog('[AUDIO] Played $label sound'); + _scheduleFocusRelease(); } on TimeoutException { - debugWarn('[AUDIO] Transmit play() timed out after 3s — resetting audio session'); - await _txPlayer?.stop(); + debugWarn('[AUDIO] $label play() timed out — resetting audio session'); + await player.stop(); await _resetAudioSession(); } catch (e) { - debugError('[AUDIO] Failed to play transmit sound: $e'); + debugError('[AUDIO] Failed to play $label sound: $e'); + // Try to recover the player for next time + try { + await player.stop(); + await player.setAsset(assetPath); + debugLog('[AUDIO] Reloaded $label player after error'); + } catch (reloadError) { + debugError('[AUDIO] Failed to reload $label player: $reloadError'); + } } } - /// Play the receive sound (when repeater echo or RX observation is detected) - Future playReceiveSound() async { - if (!_initialized || !_enabled) return; - + /// Ensure audio session is active before playback. + /// Cancels any pending focus release to prevent a race where releasing + /// focus from a previous sound kills the session for the current sound. + Future _ensureSessionActive() async { + _focusReleaseTimer?.cancel(); try { - // Seek to start and play with timeout to prevent indefinite hangs - await _rxPlayer?.seek(Duration.zero); - await _rxPlayer?.play().timeout(const Duration(seconds: 3)); - debugLog('[AUDIO] Played receive sound'); - // Release audio focus after playback completes - // Critical for Android Auto - without this, car audio stays ducked - await _releaseAudioFocus(); - } on TimeoutException { - debugWarn('[AUDIO] Receive play() timed out after 3s — resetting audio session'); - await _rxPlayer?.stop(); - await _resetAudioSession(); + final session = await AudioSession.instance; + await session.setActive(true); } catch (e) { - debugError('[AUDIO] Failed to play receive sound: $e'); + debugError('[AUDIO] Failed to activate audio session: $e'); + // Continue anyway — playback may still work } } + /// Schedule a delayed audio focus release. + /// Debounced: if another sound plays within the delay window, the timer + /// resets so focus stays active throughout rapid TX→RX sequences. + /// Critical for Android Auto: eventually releases ducking so car audio resumes. + void _scheduleFocusRelease() { + _focusReleaseTimer?.cancel(); + _focusReleaseTimer = Timer(_focusReleaseDelay, () async { + try { + final session = await AudioSession.instance; + await session.setActive(false); + debugLog('[AUDIO] Audio focus released (debounced)'); + } catch (e) { + debugError('[AUDIO] Failed to release audio focus: $e'); + } + }); + } + /// Reset audio session after a play() timeout /// Stops both players, reconfigures the audio session, and reloads assets Future _resetAudioSession() async { + _focusReleaseTimer?.cancel(); try { // Stop both players await _txPlayer?.stop(); @@ -213,7 +269,8 @@ class AudioService { avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none, androidAudioAttributes: AndroidAudioAttributes( contentType: AndroidAudioContentType.sonification, - usage: AndroidAudioUsage.notification, + // Route blips to the MEDIA stream (follows media volume). See #88. + usage: AndroidAudioUsage.media, ), androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransientMayDuck, @@ -222,8 +279,8 @@ class AudioService { ); // Reload assets so players are ready for next play() - await _txPlayer?.setAsset('assets/transmitted_packet.mp3'); - await _rxPlayer?.setAsset('assets/received_packet.mp3'); + await _txPlayer?.setAsset(_txAsset); + await _rxPlayer?.setAsset(_rxAsset); debugLog('[AUDIO] Audio session reset after timeout'); } catch (e) { @@ -231,17 +288,28 @@ class AudioService { } } - /// Release audio focus after playback completes - /// This is critical for Android Auto - without explicitly releasing focus, - /// the car audio system stays ducked indefinitely - Future _releaseAudioFocus() async { + /// Play disconnect alert sound (triple beep pattern). + /// Independent of master sound toggle — this is a safety alert. + Future playAlertSound() async { + if (!_initialized || _txPlayer == null) return; + try { - final session = await AudioSession.instance; - await session.setActive(false); - debugLog('[AUDIO] Audio focus released'); + await _ensureSessionActive(); + for (int i = 0; i < 3; i++) { + await _txPlayer!.seek(Duration.zero); + await _txPlayer!.play().timeout(const Duration(seconds: 3)); + if (i < 2) { + await Future.delayed(const Duration(milliseconds: 300)); + } + } + debugLog('[AUDIO] Played disconnect alert (triple beep)'); + _scheduleFocusRelease(); + } on TimeoutException { + debugWarn('[AUDIO] Alert play() timed out — resetting audio session'); + await _txPlayer!.stop(); + await _resetAudioSession(); } catch (e) { - debugError('[AUDIO] Failed to release audio focus: $e'); - // Non-critical - audio still works, just may leave other audio ducked + debugError('[AUDIO] Failed to play alert sound: $e'); } } @@ -259,9 +327,39 @@ class AudioService { await setEnabled(!_enabled); } + /// Enable or disable TX sound notifications + Future setTxEnabled(bool enabled) async { + if (_txEnabled == enabled) return; + _txEnabled = enabled; + debugLog('[AUDIO] TX sound ${enabled ? 'enabled' : 'disabled'}'); + await _saveSetting(_txEnabledKey, enabled); + } + + /// Enable or disable RX sound notifications + Future setRxEnabled(bool enabled) async { + if (_rxEnabled == enabled) return; + _rxEnabled = enabled; + debugLog('[AUDIO] RX sound ${enabled ? 'enabled' : 'disabled'}'); + await _saveSetting(_rxEnabledKey, enabled); + } + + /// Save a single setting to Hive + Future _saveSetting(String key, dynamic value) async { + final box = await _openBoxSafely(_prefsBoxName); + if (box == null) return; + try { + await box.put(key, value); + await box.flush(); + } catch (e) { + debugError('[AUDIO] Failed to save $key: $e'); + } + } + /// Dispose of audio resources void dispose() { debugLog('[AUDIO] Disposing audio service'); + _focusReleaseTimer?.cancel(); + _focusReleaseTimer = null; _txPlayer?.dispose(); _rxPlayer?.dispose(); _txPlayer = null; diff --git a/lib/services/auto/android_auto_service.dart b/lib/services/auto/android_auto_service.dart new file mode 100644 index 00000000..47d3f105 --- /dev/null +++ b/lib/services/auto/android_auto_service.dart @@ -0,0 +1,318 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_carplay/flutter_carplay.dart'; +import 'package:uuid/uuid.dart'; + +import '../../utils/debug_logger_io.dart'; +import '../../utils/serial_task_gate.dart'; +import '../../widgets/map_widget.dart' show renderGpsMarkerPng; +import '../external_commands/external_command_models.dart'; +import '../external_surfaces/external_surface_color.dart'; +import '../external_surfaces/external_surface_publisher.dart'; +import '../watch/watch_models.dart'; +import 'auto_glance_view.dart'; +import 'car_map_channel.dart'; + +typedef AutoCommandHandler = FutureOr Function( + ExternalSessionCommand command, +); +typedef AutoCommandRefusalHandler = void Function(ExternalCommandReason reason); + +class AndroidAutoService { + AndroidAutoService({ + @visibleForTesting Duration debounceDelay = defaultDebounceDelay, + @visibleForTesting + Duration minimumNonUrgentInterval = defaultMinimumNonUrgentInterval, + @visibleForTesting CarMapChannel? carMap, + @visibleForTesting Future Function(String style)? markerRenderer, + }) : _debounceDelay = debounceDelay, + _carMap = carMap ?? CarMapChannel(), + _renderMarker = markerRenderer ?? renderGpsMarkerPng { + _pane = ExternalSurfacePublisher( + debounceDelay: debounceDelay, + minimumNonUrgentInterval: minimumNonUrgentInterval, + isEnabled: () => isSupportedPlatform && _connected && !_isDisposed, + preflightPolicy: + ExternalSurfacePreflightPolicy.throttleAgainstPublishedUrgency, + payloadBuilder: _buildView, + fingerprintBuilder: (view) => view.fingerprint, + urgencyKeyBuilder: (_) => _viewUnderConsideration?.urgencyKey, + publish: _publishPane, + onQueueError: (error) { + debugLog('[AUTO] Update queue failed: $error'); + }, + restartDebounce: false, + ); + } + + static const Duration defaultDebounceDelay = Duration(milliseconds: 250); + static const Duration defaultMinimumNonUrgentInterval = Duration(seconds: 1); + + static const Duration _markerRenderTimeout = Duration(seconds: 5); + + final Duration _debounceDelay; + final CarMapChannel _carMap; + + final Future Function(String style) _renderMarker; + + late final ExternalSurfacePublisher _pane; + + final SerialTaskGate _mapGate = SerialTaskGate(); + Timer? _mapDebounce; + + AutoGlanceView? _viewUnderConsideration; + + CarMapSettingsBuilder? _carMapSettingsBuilder; + + FlutterAndroidAuto? _auto; + + ExternalSurfaceSnapshotBuilder? _snapshotBuilder; + AutoCommandHandler? _commandHandler; + AutoCommandRefusalHandler? _onRefusal; + + String? _lastMarkerStyle; + + bool _connected = false; + bool _isDisposed = false; + + bool get isSupportedPlatform => + !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + + @visibleForTesting + bool get isConnected => _connected; + + void attach({ + required ExternalSurfaceSnapshotBuilder snapshotBuilder, + required AutoCommandHandler commandHandler, + AutoCommandRefusalHandler? onRefusal, + CarMapSettingsBuilder? carMapSettingsBuilder, + }) { + if (!isSupportedPlatform || _isDisposed) return; + + _snapshotBuilder = snapshotBuilder; + _commandHandler = commandHandler; + _onRefusal = onRefusal; + _carMapSettingsBuilder = carMapSettingsBuilder; + + _auto = FlutterAndroidAuto() + ..addListenerOnConnectionChange(_handleConnectionChange); + debugLog('[AUTO] Android Auto surface attached'); + } + + void _handleConnectionChange(ConnectionStatusTypes status) { + if (_isDisposed) return; + debugLog('[AUTO] Connection status: ${status.name}'); + + // `background` means the car is still attached but showing another app. + // Keep publishing: the driver can switch back at any moment and must not + // find a stale pane waiting. + final connected = status == ConnectionStatusTypes.connected || + status == ConnectionStatusTypes.background; + if (connected == _connected) return; + _connected = connected; + + if (!connected) { + _mapDebounce?.cancel(); + _mapDebounce = null; + // Force the next connection to send a fresh root template rather than an + // update against a template history the host no longer has. + _pane.resetPublishedState(); + return; + } + + schedule(immediate: true); + } + + /// Ask for a redraw. Cheap to call on every provider notification. + void schedule({bool immediate = false}) { + if (!isSupportedPlatform || _isDisposed || !_connected) return; + final snapshotBuilder = _snapshotBuilder; + if (snapshotBuilder == null) return; + _pane.schedule( + snapshotBuilder, + preflightKeyBuilder: _noPreflightKey, + immediate: immediate, + ); + _scheduleMapSync(immediate: immediate); + } + + static Object? _noPreflightKey() => null; + + AutoGlanceView _buildView(WatchSnapshot snapshot) { + final view = buildAutoGlanceView( + snapshot, + now: DateTime.now(), + nodeName: _carMapSettingsBuilder?.call().nodeName, + ); + _viewUnderConsideration = view; + return view; + } + + Future _publishPane( + ExternalSurfacePublication publication, + ) async { + try { + await FlutterAndroidAuto.setRootTemplate( + template: AAMapWithContentTemplate( + id: autoGlanceMapTemplateId, + contentTemplate: _buildTemplate(publication.payload), + mapActions: _buildMapActions(publication.payload), + ), + ); + return const ExternalSurfacePublishResult.published(); + } catch (e) { + debugLog('[AUTO] Publish failed: $e'); + return const ExternalSurfacePublishResult.rejected(); + } + } + + void _scheduleMapSync({required bool immediate}) { + if (immediate) { + _mapDebounce?.cancel(); + _mapDebounce = null; + unawaited(_enqueueMapSync()); + return; + } + _mapDebounce ??= Timer(_debounceDelay, () { + _mapDebounce = null; + unawaited(_enqueueMapSync()); + }); + } + + Future _enqueueMapSync() => _mapGate.run(() async { + if (_isDisposed || !_connected) return; + final snapshot = _snapshotBuilder?.call(); + final settings = _carMapSettingsBuilder?.call(); + if (snapshot == null || settings == null) return; + await _syncMap(snapshot, settings); + }).catchError((Object e) { + debugLog('[AUTO] Map sync failed: $e'); + }); + + Future _syncMap(WatchSnapshot snapshot, CarMapSettings settings) async { + final you = snapshot.geo.you; + if (you != null) { + await _carMap.setCamera( + lat: you.lat, + lon: you.lon, + bearing: settings.northUp ? null : you.headingDeg, + heading: you.headingDeg, + ); + } + + await _carMap.setStyle(settings.styleUrl); + await _carMap.setCoverage(settings.coverage); + await _carMap.setPings(snapshot.geo.pings); + + final endsAt = snapshot.core.phaseEndsAt; + final durationMs = snapshot.phaseDurationMs; + await _carMap.setTimer( + endsAt: durationMs == null ? null : endsAt, + durationMs: endsAt == null ? null : durationMs, + argbColor: _argb(snapshot.pingColor), + ); + + await _syncPositionMarker(settings); + } + + Future _syncPositionMarker(CarMapSettings settings) async { + if (settings.markerStyle == _lastMarkerStyle) return; + try { + final png = await _renderMarker(settings.markerStyle) + .timeout(_markerRenderTimeout); + await _carMap.setPositionMarker( + style: settings.markerStyle, + png: png, + facesHeading: settings.markerFacesHeading, + ); + _lastMarkerStyle = settings.markerStyle; + } catch (e) { + + debugLog('[AUTO] Could not render the position marker: $e'); + } + } + + AAPaneTemplate _buildTemplate(AutoGlanceView view) { + return AAPaneTemplate( + id: autoGlanceTemplateId, + title: autoGlanceTitle, + items: [ + for (final row in view.rows) + AAPaneItem(title: row.title, detail: row.detail), + ], + ); + } + + List _buildMapActions(AutoGlanceView view) { + final renderedSessionId = view.sessionId; + final running = renderedSessionId != null; + + return [ + AAMapAction( + id: autoGlanceToggleActionId, + title: running ? 'Stop' : 'Start', + imageUrl: running ? 'assets/car_stop.png' : 'assets/car_start.png', + isPrimary: true, + onPress: () => _send( + ExternalSessionCommand( + id: const Uuid().v4(), + source: ExternalCommandSource.androidAuto, + kind: running + ? ExternalSessionCommandKind.stopSession + : ExternalSessionCommandKind.startSession, + issuedAt: DateTime.now(), + sessionId: running ? renderedSessionId : null, + ), + ), + ), + ]; + } + + void _send(ExternalSessionCommand command) { + final handler = _commandHandler; + if (handler == null || _isDisposed) return; + debugLog('[AUTO] Command: ${command.kind.name}'); + + final refusal = handler(command); + if (refusal is Future) { + unawaited(refusal.then(_handleRefusal)); + return; + } + _handleRefusal(refusal); + } + + void _handleRefusal(ExternalCommandReason? reason) { + if (reason == null || _isDisposed) return; + debugLog('[AUTO] Refused: ${reason.compactText}'); + _onRefusal?.call(reason); + schedule(immediate: true); + } + + void dispose() { + _isDisposed = true; + _mapDebounce?.cancel(); + _mapDebounce = null; + _pane.dispose(); + if (isSupportedPlatform) { + try { + _auto?.closeConnection(); + } catch (_) {} + } + _auto = null; + _snapshotBuilder = null; + _commandHandler = null; + _onRefusal = null; + _carMapSettingsBuilder = null; + _viewUnderConsideration = null; + } +} + +int? _argb(ExternalSurfaceColor? color) { + if (color == null) return null; + int channel(double v) => (v * 255.0).round().clamp(0, 255); + return (0xFF << 24) | + (channel(color.r) << 16) | + (channel(color.g) << 8) | + channel(color.b); +} diff --git a/lib/services/auto/auto_glance_view.dart b/lib/services/auto/auto_glance_view.dart new file mode 100644 index 00000000..bafe28b3 --- /dev/null +++ b/lib/services/auto/auto_glance_view.dart @@ -0,0 +1,90 @@ +import '../live_activity/live_activity_models.dart'; +import '../watch/watch_models.dart'; + +class AutoGlanceView { + const AutoGlanceView({ + required this.rows, + required this.sessionId, + required this.fingerprint, + required this.urgencyKey, + }); + + final List rows; + final String? sessionId; + final String fingerprint; + final String urgencyKey; +} + +class AutoGlanceRow { + const AutoGlanceRow({required this.title, required this.detail}); + + final String title; + final String detail; +} + +const String autoGlanceFallbackTitle = 'MeshMapper'; +const String autoGlanceTemplateId = 'meshmapper-glance'; +const String autoGlanceMapTemplateId = 'meshmapper-glance-map'; +const String autoGlanceToggleActionId = 'meshmapper-glance-toggle'; +const String autoGlanceTitle = ''; + +AutoGlanceView buildAutoGlanceView( + WatchSnapshot snapshot, { + required DateTime now, + String? nodeName, +}) { + final core = snapshot.core; + final controls = snapshot.controls; + + final title = (nodeName == null || nodeName.isEmpty) + ? autoGlanceFallbackTitle + : nodeName; + + final rows = [ + AutoGlanceRow( + title: title, + detail: [ + _sessionDetail(snapshot, now: now), + _trafficDetail(core), + if (!core.isConnected) 'Disconnected', + if (controls.blockedReason case final reason? + when reason.isNotEmpty && core.isConnected) + reason, + ].join(' · '), + ), + ]; + + final cue = snapshot.cue; + return AutoGlanceView( + rows: rows, + sessionId: controls.isSessionActive ? core.sessionId : null, + fingerprint: rows.map((row) => '${row.title}=${row.detail}').join('|'), + urgencyKey: [ + core.sessionId, + controls.isSessionActive.toString(), + core.phase.wireValue, + core.isConnected.toString(), + if (cue != null && cue.isPresentableAt(now)) cue.id else '', + controls.blockedReason ?? '', + ].join('|'), + ); +} + +String _sessionDetail(WatchSnapshot snapshot, {required DateTime now}) { + final core = snapshot.core; + final cue = snapshot.cue; + final message = cue != null && cue.isPresentableAt(now) ? cue.message : null; + final detail = message ?? core.phaseDetail; + if (detail == null || detail.isEmpty) return core.phaseTitle; + return '${core.phaseTitle} · $detail'; +} + +String _trafficDetail(LiveActivitySnapshot core) { + final parts = [ + 'TX ${core.txCount}', + 'RX ${core.rxCount}', + 'Disc ${core.discoveryCount}', + ]; + if (core.queueSize > 0) parts.add('Queue ${core.queueSize}'); + return parts.join(' · '); +} diff --git a/lib/services/auto/car_map_channel.dart b/lib/services/auto/car_map_channel.dart new file mode 100644 index 00000000..e31c6933 --- /dev/null +++ b/lib/services/auto/car_map_channel.dart @@ -0,0 +1,261 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../../utils/coverage_tile_palette.dart'; +import '../external_surfaces/external_surface_color.dart'; +import '../external_surfaces/geo/external_surface_geo_models.dart'; + +import '../../utils/debug_logger_io.dart'; + +typedef CarMapSettingsBuilder = CarMapSettings Function(); + +@immutable +class CarMapSettings { + const CarMapSettings({ + required this.styleUrl, + required this.coverage, + required this.northUp, + required this.markerStyle, + required this.markerFacesHeading, + required this.nodeName, + }); + + + final String styleUrl; + final CarMapCoverage? coverage; + final bool northUp; + final String markerStyle; + final bool markerFacesHeading; + final String? nodeName; + + static CarMapSettings from({ + required String styleUrl, + required String? zoneCode, + required bool tilesEnabled, + required int gridSize, + required String colorVisionType, + required double opacity, + required bool mapAlwaysNorth, + required bool mapRotationLocked, + required String markerStyle, + required bool markerFacesHeading, + String? nodeName, + }) => + CarMapSettings( + styleUrl: styleUrl, + coverage: CarMapCoverage.forZone( + zoneCode: zoneCode, + tilesEnabled: tilesEnabled, + gridSize: gridSize, + colorVisionType: colorVisionType, + opacity: opacity, + ), + northUp: mapAlwaysNorth || mapRotationLocked, + markerStyle: markerStyle, + markerFacesHeading: markerFacesHeading, + nodeName: nodeName, + ); +} + +@immutable +class CarMapCoverage { + const CarMapCoverage({ + required this.tileUrl, + required this.fillColor, + required this.outlineColor, + required this.opacity, + this.minZoom = 7, + this.maxZoom = 14, + }); + + final String tileUrl; + + final String fillColor; + final String outlineColor; + + final double opacity; + final double minZoom; + final double maxZoom; + + static CarMapCoverage? forZone({ + required String? zoneCode, + required int gridSize, + required String colorVisionType, + required double opacity, + bool tilesEnabled = true, + }) { + + if (!tilesEnabled) return null; + if (zoneCode == null || zoneCode.isEmpty || opacity <= 0) return null; + final zone = zoneCode.toLowerCase(); + return CarMapCoverage( + tileUrl: + 'https://$zone.meshmapper.net/vector_tile.php?z={z}&x={x}&y={y}&gsize=$gridSize', + fillColor: jsonEncode( + CoverageTilePalette.fillColorExpression(colorVisionType), + ), + outlineColor: jsonEncode( + CoverageTilePalette.borderColorExpression(colorVisionType), + ), + opacity: opacity, + ); + } + + Map toArguments() => { + 'tileUrl': tileUrl, + 'fillColor': fillColor, + 'outlineColor': outlineColor, + 'opacity': opacity, + 'minZoom': minZoom, + 'maxZoom': maxZoom, + }; + + @override + bool operator ==(Object other) => + other is CarMapCoverage && + other.tileUrl == tileUrl && + other.fillColor == fillColor && + other.outlineColor == outlineColor && + other.opacity == opacity && + other.minZoom == minZoom && + other.maxZoom == maxZoom; + + @override + int get hashCode => Object.hash( + tileUrl, + fillColor, + outlineColor, + opacity, + minZoom, + maxZoom, + ); +} + +class CarMapChannel { + CarMapChannel({@visibleForTesting MethodChannel? channel}) + : _channel = channel ?? const MethodChannel(_channelName); + + static const String _channelName = 'meshmapper/car_map'; + + final MethodChannel _channel; + + double? _lastLat; + double? _lastLon; + double? _lastHeading; + String? _lastMarkerStyle; + CarMapCoverage? _lastCoverage; + bool _hasSentCoverage = false; + String? _lastPings; + String? _lastTimer; + + Future setCamera({ + required double lat, + required double lon, + double? bearing, + double? heading, + double? zoom, + }) async { + // Heading is part of the dedupe: standing still while turning still has to + // move the marker. + if (_sameToFiveDecimals(lat, _lastLat) && + _sameToFiveDecimals(lon, _lastLon) && + heading == _lastHeading) { + return; + } + _lastLat = lat; + _lastLon = lon; + _lastHeading = heading; + await _invoke('setCamera', { + 'lat': lat, + 'lon': lon, + 'bearing': bearing, + 'heading': heading, + 'zoom': zoom, + }); + } + + Future setStyle(String url) async { + if (url.isEmpty) return; + await _invoke('setStyle', {'url': url}); + } + + Future setCoverage(CarMapCoverage? coverage) async { + if (_hasSentCoverage && coverage == _lastCoverage) return; + _hasSentCoverage = true; + _lastCoverage = coverage; + await _invoke('setCoverage', coverage?.toArguments() ?? const {}); + } + + Future setPings(List pings) async { + final geoJson = encodePingsGeoJson(pings); + if (geoJson == _lastPings) return; + _lastPings = geoJson; + await _invoke('setPings', {'geoJson': geoJson}); + } + + Future setTimer({ + DateTime? endsAt, + int? durationMs, + int? argbColor, + }) async { + final key = '${endsAt?.millisecondsSinceEpoch}|$durationMs|$argbColor'; + if (key == _lastTimer) return; + _lastTimer = key; + await _invoke('setTimer', { + 'endsAtMs': endsAt?.millisecondsSinceEpoch, + 'durationMs': durationMs, + 'color': argbColor, + }); + } + + Future setPositionMarker({ + required String style, + required Uint8List png, + required bool facesHeading, + }) async { + if (style == _lastMarkerStyle) return; + _lastMarkerStyle = style; + await _invoke('setPositionMarker', { + 'png': png, + 'facesHeading': facesHeading, + }); + } + + Future _invoke(String method, Map args) async { + try { + await _channel.invokeMethod(method, args); + } on MissingPluginException { + } catch (e) { + debugLog('[AUTO] Car map $method failed: $e'); + } + } + + static bool _sameToFiveDecimals(double value, double? previous) => + previous != null && (value - previous).abs() < 0.00001; +} + +String encodePingsGeoJson(List pings) => jsonEncode({ + 'type': 'FeatureCollection', + 'features': [ + for (final ping in pings) + { + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [ping.lon, ping.lat], + }, + 'properties': { + 'color': _hex(ping.color), + 'kind': ping.kind, + }, + }, + ], + }); + +String _hex(ExternalSurfaceColor color) { + String channel(double v) => + (v * 255.0).round().clamp(0, 255).toRadixString(16).padLeft(2, '0'); + return '#${channel(color.r)}${channel(color.g)}${channel(color.b)}'; +} diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart index 49a110c0..99f6cf7a 100644 --- a/lib/services/background_service.dart +++ b/lib/services/background_service.dart @@ -29,9 +29,11 @@ class BackgroundServiceManager { static bool _isRunning = false; /// Initialize the background service. - /// Called lazily on first startService() call. - /// On Android, configure() may start the foreground service as a side effect, - /// so this should NOT be called eagerly at app startup. + /// Called lazily on first startService() call — never at app startup. + /// On Android, configure() may start the foreground service as a side effect + /// (service resurrection), so this MUST only be called when we actually + /// intend to start the service. Startup orphan cleanup uses + /// cleanupOrphanedService() which cancels the notification directly. static Future initialize() async { // Skip on web - no background service support if (kIsWeb) { @@ -80,6 +82,7 @@ class BackgroundServiceManager { foregroundServiceTypes: [ AndroidForegroundType.location, AndroidForegroundType.connectedDevice, + AndroidForegroundType.dataSync, ], ), iosConfiguration: IosConfiguration( @@ -93,7 +96,8 @@ class BackgroundServiceManager { // (e.g., Android resurrecting a previously-killed foreground service). final isRunning = await _service!.isRunning(); if (isRunning) { - debugLog('[BACKGROUND] Service unexpectedly running after configure(), stopping it'); + debugLog( + '[BACKGROUND] Service unexpectedly running after configure(), stopping it'); _service!.invoke('stop'); } @@ -207,14 +211,37 @@ class BackgroundServiceManager { /// Clean up any orphaned foreground service from a previous app session. /// On Android, the foreground service can survive app process death. /// Call this at app startup to ensure no stale notification persists. + /// + /// This intentionally does NOT call BackgroundService.configure()/initialize(), + /// because configure() with isForegroundMode:true can cause Android to + /// resurrect a killed foreground service as a side effect — producing a + /// phantom notification hours later (the "3am notification" bug). + /// + /// We DO initialize the local-notifications plugin first: cancel() silently + /// no-ops until the plugin's platform channel is initialized, which is why + /// the orphaned notification kept reappearing after a reboot (#178). + /// Initializing the notifications plugin has no foreground-service side + /// effects — only configure() does. static Future cleanupOrphanedService() async { if (kIsWeb) return; try { - debugLog('[BACKGROUND] Checking for orphaned service from previous session'); - await initialize(); - // initialize() has a defense check that stops any running service + debugLog( + '[BACKGROUND] Dismissing any orphaned notification from previous session'); + final plugin = FlutterLocalNotificationsPlugin(); + // Establish the platform channel so cancel() actually reaches native. + const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher'); + const iosInit = DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + requestSoundPermission: false, + ); + await plugin.initialize( + const InitializationSettings(android: androidInit, iOS: iosInit), + ); + await plugin.cancel(_notificationId); + debugLog('[BACKGROUND] Orphaned notification cleanup complete'); } catch (e) { - debugError('[BACKGROUND] Failed to cleanup orphaned service: $e'); + debugError('[BACKGROUND] Failed to cleanup orphaned notification: $e'); } } @@ -249,6 +276,8 @@ class BackgroundServiceManager { String body; if (mode == 'Passive Mode') { body = 'RX: $rxCount | Queue: $queueSize'; + } else if (mode == 'Trace Mode') { + body = 'Trace: $txCount | RX: $rxCount | Queue: $queueSize'; } else { body = 'TX: $txCount | RX: $rxCount | Queue: $queueSize'; } diff --git a/lib/services/bluetooth/ble_connect_retry_policy.dart b/lib/services/bluetooth/ble_connect_retry_policy.dart new file mode 100644 index 00000000..f05d05f5 --- /dev/null +++ b/lib/services/bluetooth/ble_connect_retry_policy.dart @@ -0,0 +1,87 @@ +/// Pure retry-policy decisions for BLE connection failures (#500). +/// +/// connect() used to retry only Android error 133, so the 15s GATT connect +/// timeout, transient GATT errors and service discovery failures all gave up +/// on attempt 1 of 3, and users re-tapped Connect to do the loop's work. +/// These decisions are pure functions so the policy is unit-testable without +/// flutter_blue_plus. +library; + +/// What connect() should do with a failed attempt. +enum BleConnectFailureAction { + /// Transient failure: clean up and run the next attempt. + retry, + + /// iOS stale-bond error (apple-code 14/15). Internal retries all fail the + /// same way and each hangs for the full connect timeout, so abort now and + /// let the auto-reconnect system apply a longer delay. + abortForBondError, + + /// Attempts exhausted: surface the error to the caller. + fail, +} + +/// Decide how connect() handles a failed attempt. +/// +/// Every failure is retryable while attempts remain, except iOS bond errors +/// which abort immediately (stale keys cannot be cleared programmatically). +BleConnectFailureAction classifyBleConnectFailure({ + required String errorString, + required bool isIos, + required int attempt, + required int maxRetries, +}) { + final isBondError = isIos && + (errorString.contains('apple-code: 14') || + errorString.contains('apple-code: 15') || + errorString.contains('Peer removed pairing information')); + if (isBondError) { + return BleConnectFailureAction.abortForBondError; + } + if (attempt < maxRetries) { + return BleConnectFailureAction.retry; + } + return BleConnectFailureAction.fail; +} + +/// How soon after a successful transport connect a workflow death still counts +/// as "the link died right after connecting". 20s covers a single 15s +/// writeCharacteristic timeout on the first protocol command. +const Duration handshakeRerunWindow = Duration(seconds: 20); + +/// Decide whether connectToDevice() re-runs the 9-step connection workflow +/// after it died shortly after a successful transport connect. +/// +/// Deliberately excluded: +/// - server auth verdicts (AUTH_FAILED:) are deterministic, a re-run just +/// hits the server again with the same answer; +/// - auto-reconnect already re-runs the whole workflow per attempt; +/// - a disposed connection means something else tore the attempt down. +bool shouldRerunConnectionWorkflow({ + required bool transportConnected, + required Duration? sinceTransportConnect, + required String errorString, + required bool isAutoReconnecting, + required bool userRequestedDisconnect, + required bool alreadyRerun, +}) { + if (alreadyRerun || isAutoReconnecting || userRequestedDisconnect) { + return false; + } + if (!transportConnected || sinceTransportConnect == null) { + return false; + } + if (sinceTransportConnect > handshakeRerunWindow) { + return false; + } + if (errorString.contains('AUTH_FAILED:')) { + return false; + } + if (errorString.contains('has been disposed')) { + return false; + } + if (errorString.contains('superseded')) { + return false; + } + return true; +} diff --git a/lib/services/bluetooth/bluetooth_service.dart b/lib/services/bluetooth/bluetooth_service.dart index e1fe5eb6..d6d0d871 100644 --- a/lib/services/bluetooth/bluetooth_service.dart +++ b/lib/services/bluetooth/bluetooth_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:typed_data'; import '../../models/connection_state.dart'; +import '../transport/companion_transport.dart'; /// Exception thrown when BLE permissions are permanently denied /// User must enable permissions in device Settings @@ -41,20 +42,24 @@ enum BluetoothAdapterState { /// Abstract Bluetooth service interface /// Platform implementations provided by MobileBluetoothService and WebBluetoothService -abstract class BluetoothService { +abstract class BluetoothService implements CompanionTransport { /// Stream of connection status changes + @override Stream get connectionStream; /// Stream of received data from device + @override Stream get dataStream; /// Stream of Bluetooth adapter state changes (on/off) Stream get adapterStateStream; /// Current connection status + @override ConnectionStatus get connectionStatus; /// Currently connected device (null if not connected) + @override DiscoveredDevice? get connectedDevice; /// Check if Bluetooth is available on this platform @@ -73,19 +78,33 @@ abstract class BluetoothService { /// Stop scanning for devices Future stopScan(); - /// Connect to a device by ID - Future connect(String deviceId); + /// Connect to a device by ID. + /// + /// [maxAttempts] caps the implementation's internal retry loop. The + /// auto-reconnect path passes 1: its own retry ladder and 30s budget + /// replace the internal retries, which would otherwise consume the whole + /// budget inside one call. + Future connect(String deviceId, {int? maxAttempts}); /// Disconnect from current device + @override Future disconnect(); /// Write data to device + @override Future write(Uint8List data); /// Pre-populate device cache with known device info /// Used for remembered devices to ensure name is available during connect void cacheDeviceInfo(DiscoveredDevice device); + /// Remove BLE bond/pairing for a device + /// On Android: removes the system bond entry + /// On iOS: best-effort — calls cancelPeripheralConnection to nudge CoreBluetooth + /// into clearing stale encryption keys (used after apple-code 14 errors) + Future removeBond(String deviceId); + /// Dispose of resources + @override void dispose(); } diff --git a/lib/services/bluetooth/mobile_bluetooth.dart b/lib/services/bluetooth/mobile_bluetooth.dart index 6131f832..36f83be1 100644 --- a/lib/services/bluetooth/mobile_bluetooth.dart +++ b/lib/services/bluetooth/mobile_bluetooth.dart @@ -9,12 +9,13 @@ import 'package:permission_handler/permission_handler.dart'; import '../../models/connection_state.dart'; import '../../utils/debug_logger_io.dart'; import '../meshcore/protocol_constants.dart'; +import 'ble_connect_retry_policy.dart'; import 'bluetooth_service.dart'; /// Mobile Bluetooth implementation using flutter_blue_plus /// For Android and iOS platforms class MobileBluetoothService implements BluetoothService { - // Retry constants for Android BLE error 133 (GATT_ERROR) + // Retry constants for transient connect failures (see ble_connect_retry_policy.dart) static const int _maxRetries = 3; static const Duration _retryDelay = Duration(milliseconds: 500); @@ -35,10 +36,13 @@ class MobileBluetoothService implements BluetoothService { } void _ensureControllers() { - if (_isDisposed || _connectionController == null || _connectionController!.isClosed) { + if (_isDisposed || + _connectionController == null || + _connectionController!.isClosed) { _initControllers(); } } + DiscoveredDevice? _connectedDevice; fbp.BluetoothDevice? _bleDevice; fbp.BluetoothCharacteristic? _rxCharacteristic; @@ -46,6 +50,7 @@ class MobileBluetoothService implements BluetoothService { StreamSubscription? _connectionStateSubscription; StreamSubscription? _notificationSubscription; StreamSubscription? _scanSubscription; + StreamController? _scanController; // Store scanned device info for use in connect() // This preserves the device name from scan results @@ -134,19 +139,21 @@ class MobileBluetoothService implements BluetoothService { debugLog('[BLE] iOS location permission check: $locationPermission'); if (locationPermission == LocationPermission.deniedForever) { - debugLog('[BLE] iOS location permission permanently denied - user must enable in Settings'); + debugLog( + '[BLE] iOS location permission permanently denied - user must enable in Settings'); throw BlePermissionDeniedException( - 'Location permission required for Bluetooth scanning. ' - 'Please enable in Settings > Privacy & Security > Location Services > MeshMapper' - ); + 'Location permission required for Bluetooth scanning. ' + 'Please enable in Settings > Privacy & Security > Location Services > MeshMapper'); } if (locationPermission == LocationPermission.denied) { - debugLog('[BLE] iOS location permission not yet granted (disclosure flow will handle)'); + debugLog( + '[BLE] iOS location permission not yet granted (disclosure flow will handle)'); return false; } - debugLog('[BLE] iOS permissions OK - Core Bluetooth will prompt for Bluetooth access when scanning'); + debugLog( + '[BLE] iOS permissions OK - Core Bluetooth will prompt for Bluetooth access when scanning'); return true; } else { // Android: Use bluetoothScan and bluetoothConnect (Android 12+) @@ -154,18 +161,26 @@ class MobileBluetoothService implements BluetoothService { // Location requests are handled by the disclosure flow in MainScaffold. final bluetoothScan = await Permission.bluetoothScan.request(); final bluetoothConnect = await Permission.bluetoothConnect.request(); - final location = await Permission.locationWhenInUse.status; // CHECK only, don't request + final location = await Permission + .locationWhenInUse.status; // CHECK only, don't request - debugLog('[BLE] Android permission check - scan: $bluetoothScan, connect: $bluetoothConnect, location: $location'); + debugLog( + '[BLE] Android permission check - scan: $bluetoothScan, connect: $bluetoothConnect, location: $location'); // Check for permanently denied permissions - if (bluetoothScan.isPermanentlyDenied || bluetoothConnect.isPermanentlyDenied || location.isPermanentlyDenied) { + if (bluetoothScan.isPermanentlyDenied || + bluetoothConnect.isPermanentlyDenied || + location.isPermanentlyDenied) { final denied = []; if (bluetoothScan.isPermanentlyDenied) denied.add('Bluetooth Scan'); - if (bluetoothConnect.isPermanentlyDenied) denied.add('Bluetooth Connect'); + if (bluetoothConnect.isPermanentlyDenied) { + denied.add('Bluetooth Connect'); + } if (location.isPermanentlyDenied) denied.add('Location'); - debugLog('[BLE] Android permissions permanently denied: ${denied.join(", ")}'); - throw BlePermissionDeniedException('${denied.join(", ")} permission(s) denied. Please enable in Settings'); + debugLog( + '[BLE] Android permissions permanently denied: ${denied.join(", ")}'); + throw BlePermissionDeniedException( + '${denied.join(", ")} permission(s) denied. Please enable in Settings'); } final granted = bluetoothScan.isGranted && @@ -183,10 +198,15 @@ class MobileBluetoothService implements BluetoothService { @override Stream scanForDevices({Duration? timeout}) async* { final controller = StreamController(); - + _scanController = controller; + _updateStatus(ConnectionStatus.scanning); try { + // Ensure any previous scan stream is closed before starting a new one + await _scanSubscription?.cancel(); + _scanSubscription = null; + // Start scanning with filter for MeshCore service UUID await fbp.FlutterBluePlus.startScan( withServices: [fbp.Guid(BleUuids.serviceUuid)], @@ -197,9 +217,11 @@ class MobileBluetoothService implements BluetoothService { _scanSubscription = fbp.FlutterBluePlus.scanResults.listen((results) { for (final result in results) { final hasName = result.device.platformName.isNotEmpty; - final deviceName = hasName ? result.device.platformName : 'MeshCore Device'; + final deviceName = + hasName ? result.device.platformName : 'MeshCore Device'; if (!hasName) { - debugLog('[BLE] WARNING: Device ${result.device.remoteId.str} has no platformName during scan, using fallback "MeshCore Device"'); + debugLog( + '[BLE] WARNING: Device ${result.device.remoteId.str} has no platformName during scan, using fallback "MeshCore Device"'); } final device = DiscoveredDevice( id: result.device.remoteId.str, @@ -208,14 +230,32 @@ class MobileBluetoothService implements BluetoothService { ); // Store device info for use in connect() - preserves name from scan _scannedDevices[device.id] = device; - controller.add(device); + if (!controller.isClosed) { + controller.add(device); + } } }); + // Complete stream when scan naturally stops (timeout or platform stop) + unawaited(() async { + await fbp.FlutterBluePlus.isScanning + .where((isScanning) => !isScanning) + .first; + if (!controller.isClosed) { + await controller.close(); + } + }()); + yield* controller.stream; } finally { await _scanSubscription?.cancel(); _scanSubscription = null; + if (!controller.isClosed) { + await controller.close(); + } + if (identical(_scanController, controller)) { + _scanController = null; + } } } @@ -224,18 +264,35 @@ class MobileBluetoothService implements BluetoothService { await fbp.FlutterBluePlus.stopScan(); await _scanSubscription?.cancel(); _scanSubscription = null; + final scanController = _scanController; + if (scanController != null && !scanController.isClosed) { + await scanController.close(); + } + _scanController = null; // NOTE: Do NOT fire 'disconnected' here. Stopping a scan is not a disconnection. // The status will be updated by connect() when a connection starts. // Firing 'disconnected' here causes a race condition where the queued event // arrives after a new MeshCoreConnection is created and disposes it incorrectly. } + /// Bumped by every connect() call. A loop whose epoch is stale has been + /// superseded by a newer connect and must stop retrying without touching + /// the shared device state (_bleDevice, characteristics, subscriptions). + int _connectEpoch = 0; + @override - Future connect(String deviceId) async { + Future connect(String deviceId, {int? maxAttempts}) async { + final epoch = ++_connectEpoch; + final requested = maxAttempts ?? _maxRetries; + final attemptLimit = + requested < 1 ? 1 : (requested > _maxRetries ? _maxRetries : requested); int attempt = 0; - while (attempt < _maxRetries) { + while (attempt < attemptLimit) { attempt++; + if (epoch != _connectEpoch) { + throw Exception('Connection attempt superseded'); + } try { debugLog('[BLE] Connection attempt $attempt/$_maxRetries to $deviceId'); _updateStatus(ConnectionStatus.connecting); @@ -269,7 +326,8 @@ class MobileBluetoothService implements BluetoothService { debugLog('[BLE] Connecting to GATT...'); await _bleDevice!.connect( timeout: const Duration(seconds: 15), - mtu: null, // Disable automatic MTU negotiation during connect to avoid race condition errors on Android + mtu: + null, // Disable automatic MTU negotiation during connect to avoid race condition errors on Android ); debugLog('[BLE] GATT connected'); @@ -286,7 +344,8 @@ class MobileBluetoothService implements BluetoothService { } catch (e) { // MTU negotiation failure is not fatal - continue with default MTU // Some older devices may not support MTU negotiation - debugLog('[BLE] MTU negotiation failed (continuing with default): $e'); + debugLog( + '[BLE] MTU negotiation failed (continuing with default): $e'); } } else { // iOS auto-negotiates MTU, just log the current value @@ -299,7 +358,8 @@ class MobileBluetoothService implements BluetoothService { // Flutter Blue Plus emits the current state immediately when you subscribe, // but we only want to react to CHANGES, not the initial state. // This prevents false disconnection triggers during connection setup. - _connectionStateSubscription = _bleDevice!.connectionState.skip(1).listen((state) { + _connectionStateSubscription = + _bleDevice!.connectionState.skip(1).listen((state) { debugLog('[BLE] Connection state changed: $state'); if (state == fbp.BluetoothConnectionState.disconnected) { _handleDisconnection(); @@ -337,8 +397,11 @@ class MobileBluetoothService implements BluetoothService { // Enable notifications on TX characteristic debugLog('[BLE] Enabling notifications...'); await _txCharacteristic!.setNotifyValue(true); - _notificationSubscription = _txCharacteristic!.lastValueStream.listen((value) { - if (value.isNotEmpty && _dataController != null && !_dataController!.isClosed) { + _notificationSubscription = + _txCharacteristic!.lastValueStream.listen((value) { + if (value.isNotEmpty && + _dataController != null && + !_dataController!.isClosed) { _dataController!.add(Uint8List.fromList(value)); } }); @@ -353,43 +416,64 @@ class MobileBluetoothService implements BluetoothService { deviceName = _bleDevice!.platformName; } else { deviceName = 'MeshCore Device'; - debugLog('[BLE] WARNING: No device name available for $deviceId during connect - no scan cache, empty platformName. Using fallback "MeshCore Device"'); + debugLog( + '[BLE] WARNING: No device name available for $deviceId during connect - no scan cache, empty platformName. Using fallback "MeshCore Device"'); } _connectedDevice = DiscoveredDevice( id: deviceId, name: deviceName, ); if (deviceName == 'MeshCore Device') { - debugLog('[BLE] WARNING: Connected device name is "MeshCore Device" (from scan: ${scannedDevice != null}, scanName: ${scannedDevice?.name}, platformName: ${_bleDevice!.platformName})'); + debugLog( + '[BLE] WARNING: Connected device name is "MeshCore Device" (from scan: ${scannedDevice != null}, scanName: ${scannedDevice?.name}, platformName: ${_bleDevice!.platformName})'); } else { - debugLog('[BLE] Device name: $deviceName (from scan: ${scannedDevice != null}, platformName: ${_bleDevice!.platformName})'); + debugLog( + '[BLE] Device name: $deviceName (from scan: ${scannedDevice != null}, platformName: ${_bleDevice!.platformName})'); } + if (epoch != _connectEpoch) { + throw Exception('Connection attempt superseded'); + } debugLog('[BLE] Connection complete'); _updateStatus(ConnectionStatus.connected); return; // Success - exit retry loop - } catch (e, stackTrace) { - // Check for Android error 133 (GATT_ERROR) - a well-known Android BLE stack issue - // that typically succeeds on retry - final isError133 = Platform.isAndroid && e.toString().contains('android-code: 133'); + if (epoch != _connectEpoch) { + // A newer connect() owns the shared device state; retrying or + // cleaning up here would stomp its fields mid-attempt. + rethrow; + } + final action = classifyBleConnectFailure( + errorString: e.toString(), + isIos: Platform.isIOS, + attempt: attempt, + maxRetries: attemptLimit, + ); + + if (action == BleConnectFailureAction.abortForBondError) { + debugLog('[BLE] Bond error (apple-code 14/15) on attempt $attempt'); + await removeBond(deviceId); + // On iOS, removeBond() is a no-op (not supported by Core Bluetooth). + // Don't burn internal retries against stale bond keys: they will all + // fail the same way and each hangs for the full connect timeout. + // Rethrow immediately so the auto-reconnect system can apply a longer + // delay (giving iOS time to resolve) or inform the user. + debugWarn( + '[BLE] iOS bond error, skipping internal retries (stale keys cannot be cleared programmatically)'); + await _cleanupFailedAttempt(); + _updateStatus(ConnectionStatus.error); + rethrow; + } - if (isError133 && attempt < _maxRetries) { - debugLog('[BLE] Error 133 on attempt $attempt, retrying after delay...'); + if (action == BleConnectFailureAction.retry) { + debugLog( + '[BLE] Attempt $attempt failed, retrying after delay... ($e)'); + await _cleanupFailedAttempt(); await Future.delayed(_retryDelay); - // Force cleanup before retry - try { - await _bleDevice?.disconnect(); - } catch (e) { - debugWarn('[BLE] Failed to disconnect before retry: $e'); - } - _bleDevice = null; - _rxCharacteristic = null; - _txCharacteristic = null; continue; // Retry } - // Final attempt failed or non-retryable error + // Final attempt failed debugLog('[BLE] Connection error: $e'); debugLog('[BLE] Stack trace: $stackTrace'); _updateStatus(ConnectionStatus.error); @@ -398,6 +482,26 @@ class MobileBluetoothService implements BluetoothService { } } + /// Tear down a failed connect attempt without emitting a disconnected event. + /// The state subscription is cancelled BEFORE the disconnect: attempts that + /// fail after the GATT link is up (discovery, notifications) have the + /// listener installed, and letting it fire _handleDisconnection would emit + /// 'disconnected' and start full provider cleanup mid-retry. + Future _cleanupFailedAttempt() async { + await _connectionStateSubscription?.cancel(); + _connectionStateSubscription = null; + await _notificationSubscription?.cancel(); + _notificationSubscription = null; + try { + await _bleDevice?.disconnect(); + } catch (e) { + debugWarn('[BLE] Failed to disconnect failed attempt: $e'); + } + _bleDevice = null; + _rxCharacteristic = null; + _txCharacteristic = null; + } + void _handleDisconnection() { // Guard against double-disconnect: when disconnect() is called, the BLE // connectionState listener fires first, then the finally block also calls @@ -443,6 +547,19 @@ class MobileBluetoothService implements BluetoothService { debugLog('[BLE] Cached device info: ${device.name} (${device.id})'); } + @override + Future removeBond(String deviceId) async { + try { + final device = fbp.BluetoothDevice.fromId(deviceId); + debugLog('[BLE] Removing bond for $deviceId'); + await device.removeBond(); + debugLog('[BLE] Bond removed for $deviceId'); + } catch (e) { + // removeBond may not be supported on all platforms/devices — log and continue + debugLog('[BLE] removeBond failed (continuing): $e'); + } + } + @override void dispose() { _isDisposed = true; diff --git a/lib/services/bluetooth/web_bluetooth.dart b/lib/services/bluetooth/web_bluetooth.dart index 81054ede..eff37be5 100644 --- a/lib/services/bluetooth/web_bluetooth.dart +++ b/lib/services/bluetooth/web_bluetooth.dart @@ -13,14 +13,16 @@ import 'bluetooth_service.dart'; class WebBluetoothService implements BluetoothService { final _connectionController = StreamController.broadcast(); final _dataController = StreamController.broadcast(); - final fwb.FlutterWebBluetoothInterface _webBluetooth = fwb.FlutterWebBluetooth.instance; + final fwb.FlutterWebBluetoothInterface _webBluetooth = + fwb.FlutterWebBluetooth.instance; ConnectionStatus _connectionStatus = ConnectionStatus.disconnected; DiscoveredDevice? _connectedDevice; fwb.BluetoothDevice? _device; fwb.BluetoothDevice? _pendingDevice; // Store device from scan for connect() - fwb.BluetoothCharacteristic? _rxCharacteristic; // For writing (device RX) - fwb.BluetoothCharacteristic? _txCharacteristic; // For notifications (device TX) + fwb.BluetoothCharacteristic? _rxCharacteristic; // For writing (device RX) + fwb.BluetoothCharacteristic? + _txCharacteristic; // For notifications (device TX) StreamSubscription? _notificationSubscription; @override @@ -73,13 +75,15 @@ class WebBluetoothService implements BluetoothService { // Web Bluetooth doesn't support scanning - uses requestDevice dialog // This is a stub that will yield devices from the request dialog _updateStatus(ConnectionStatus.scanning); - debugLog('[BLE] Opening device picker with service filter: ${BleUuids.serviceUuid}'); - + debugLog( + '[BLE] Opening device picker with service filter: ${BleUuids.serviceUuid}'); + try { // Request device filtered by MeshCore service UUID (matches JS implementation) final device = await _webBluetooth.requestDevice( fwb.RequestOptionsBuilder([ - fwb.RequestFilterBuilder(services: [BleUuids.serviceUuid.toLowerCase()]), + fwb.RequestFilterBuilder( + services: [BleUuids.serviceUuid.toLowerCase()]), ]), ); @@ -89,7 +93,8 @@ class WebBluetoothService implements BluetoothService { final deviceName = device.name ?? 'MeshCore Device'; if (device.name == null) { - debugWarn('[BLE] WARNING: Device ${device.id} has no name during scan, using fallback "MeshCore Device"'); + debugWarn( + '[BLE] WARNING: Device ${device.id} has no name during scan, using fallback "MeshCore Device"'); } yield DiscoveredDevice( id: device.id, @@ -113,7 +118,8 @@ class WebBluetoothService implements BluetoothService { } @override - Future connect(String deviceId) async { + Future connect(String deviceId, {int? maxAttempts}) async { + // maxAttempts is a mobile concern; web connects are single-attempt. try { _updateStatus(ConnectionStatus.connecting); debugLog('[BLE] Connecting to device: $deviceId'); @@ -123,7 +129,7 @@ class WebBluetoothService implements BluetoothService { debugError('[BLE] No pending device - must call scanForDevices first'); throw Exception('No device selected. Please scan for devices first.'); } - + _device = _pendingDevice; _pendingDevice = null; // Clear pending debugLog('[BLE] Using stored device: ${_device!.name ?? _device!.id}'); @@ -137,7 +143,7 @@ class WebBluetoothService implements BluetoothService { debugLog('[BLE] Discovering services...'); final services = await _device!.discoverServices(); debugLog('[BLE] Found ${services.length} services'); - + // Find our MeshCore service fwb.BluetoothService? meshCoreService; for (final service in services) { @@ -148,7 +154,7 @@ class WebBluetoothService implements BluetoothService { break; } } - + if (meshCoreService == null) { throw Exception('MeshCore service not found'); } @@ -179,14 +185,15 @@ class WebBluetoothService implements BluetoothService { try { await _txCharacteristic!.startNotifications(); debugLog('[BLE] Notifications started, setting up listener...'); - + // HIGH-LEVEL API: BluetoothCharacteristic.value is a Stream _notificationSubscription = _txCharacteristic!.value.listen( (ByteData data) { try { // Convert ByteData to Uint8List - final buffer = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); - + final buffer = data.buffer + .asUint8List(data.offsetInBytes, data.lengthInBytes); + if (buffer.isNotEmpty) { debugLog('[BLE] Received ${buffer.length} bytes'); _dataController.add(buffer); @@ -209,7 +216,8 @@ class WebBluetoothService implements BluetoothService { final deviceName = _device!.name ?? 'MeshCore Device'; if (_device!.name == null) { - debugWarn('[BLE] WARNING: Device ${_device!.id} has no name during connect, using fallback "MeshCore Device"'); + debugWarn( + '[BLE] WARNING: Device ${_device!.id} has no name during connect, using fallback "MeshCore Device"'); } _connectedDevice = DiscoveredDevice( id: _device!.id, @@ -262,6 +270,11 @@ class WebBluetoothService implements BluetoothService { // No caching needed - this method is for mobile remembered devices } + @override + Future removeBond(String deviceId) async { + // Web Bluetooth does not support bond management + } + @override void dispose() { _notificationSubscription?.cancel(); diff --git a/lib/services/countdown_timer_service.dart b/lib/services/countdown_timer_service.dart index bf5c94a2..cdac81b6 100644 --- a/lib/services/countdown_timer_service.dart +++ b/lib/services/countdown_timer_service.dart @@ -1,22 +1,29 @@ import 'dart:async'; -import 'package:flutter/foundation.dart' show VoidCallback; +import 'package:flutter/foundation.dart'; import '../utils/debug_logger_io.dart'; -/// Countdown timer for cooldown, auto-ping, and RX window +/// Countdown timer for cooldown, auto-ping, and RX window. /// Reference: createCountdownTimer() in wardrive.js /// -/// Features: -/// - 500ms update interval for responsive countdown display -/// - Auto-stops when countdown reaches zero -class CountdownTimerService { +/// Extends ChangeNotifier so that only widgets listening to this specific timer +/// rebuild on each tick. Previously, every tick called +/// AppStateProvider.notifyListeners(), which rebuilt the entire widget tree +/// (including the expensive MapWidget) 2× per second per active timer. +class CountdownTimerService extends ChangeNotifier { Timer? _timer; DateTime? _endTime; - int? _durationMs; // Original duration for progress calculation - final VoidCallback? onUpdate; // Callback for UI refresh on each timer tick - CountdownTimerService({this.onUpdate}); + /// Absolute deadline used by system surfaces such as iOS Live Activities. + DateTime? get endTime => _endTime; + int? _durationMs; + + /// Total length of the current countdown. + /// + /// Paired with [endTime] this lets a remote surface — the watch — draw a + /// progress bar locally from absolute values, with no per-second updates. + int? get durationMs => _durationMs; /// Check if timer is running bool get isRunning => _timer != null; @@ -41,15 +48,16 @@ class CountdownTimerService { /// Start countdown timer /// @param durationMs - Duration in milliseconds void start(int durationMs) { - stop(); - _durationMs = durationMs; // Track original duration for progress + _cancelTimer(); + _durationMs = durationMs; _endTime = DateTime.now().add(Duration(milliseconds: durationMs)); // Start 500ms update timer for responsive countdown - _timer = Timer.periodic(const Duration(milliseconds: 500), (_) => _update()); + _timer = + Timer.periodic(const Duration(milliseconds: 500), (_) => _update()); // Trigger immediate update - _update(); + notifyListeners(); } /// Update countdown display @@ -60,35 +68,43 @@ class CountdownTimerService { // Stop when countdown reaches zero if (remainingMs == 0) { - stop(); - // Trigger UI refresh when timer ends so UI updates immediately - onUpdate?.call(); + _cancelTimer(); + _endTime = null; + _durationMs = null; + notifyListeners(); return; } - // Trigger UI refresh callback after each update - onUpdate?.call(); + notifyListeners(); } /// Stop countdown timer void stop() { - _timer?.cancel(); - _timer = null; + if (_timer == null) return; + _cancelTimer(); _endTime = null; _durationMs = null; + notifyListeners(); + } + + void _cancelTimer() { + _timer?.cancel(); + _timer = null; } /// Dispose resources + @override void dispose() { - stop(); + _cancelTimer(); + _endTime = null; + _durationMs = null; + super.dispose(); } } /// Specialized countdown timer for ping cooldown /// Reference: wardrive.js state.cooldownEndTime and cooldownUpdateTimer class CooldownTimer extends CountdownTimerService { - CooldownTimer({super.onUpdate}); - @override void stop() { final wasRunning = isRunning; @@ -105,8 +121,6 @@ class AutoPingTimer extends CountdownTimerService { /// Skip reason (e.g., "too close", "gps too old") String? skipReason; - AutoPingTimer({super.onUpdate}); - /// Start countdown with optional skip reason /// Reference: startAutoCountdown() in wardrive.js with state.skipReason void startWithSkipReason(int durationMs, String? reason) { @@ -117,26 +131,21 @@ class AutoPingTimer extends CountdownTimerService { /// Specialized countdown timer for RX listening window /// Reference: wardrive.js state.rxListeningEndTime -class RxWindowTimer extends CountdownTimerService { - RxWindowTimer({super.onUpdate}); -} +class RxWindowTimer extends CountdownTimerService {} /// Specialized countdown timer for discovery listening window (Passive Mode) -class DiscoveryWindowTimer extends CountdownTimerService { - DiscoveryWindowTimer({super.onUpdate}); -} +class DiscoveryWindowTimer extends CountdownTimerService {} /// Specialized countdown timer for manual ping cooldown (15 seconds) class ManualPingCooldownTimer extends CountdownTimerService { - ManualPingCooldownTimer({super.onUpdate}); - @override void stop() { final wasRunning = isRunning; final remaining = remainingMs; super.stop(); if (wasRunning) { - debugLog('[TIMER] Manual ping cooldown timer stopped (was ${remaining}ms remaining)'); + debugLog( + '[TIMER] Manual ping cooldown timer stopped (was ${remaining}ms remaining)'); } } } diff --git a/lib/services/custom_api_service.dart b/lib/services/custom_api_service.dart new file mode 100644 index 00000000..243b5d64 --- /dev/null +++ b/lib/services/custom_api_service.dart @@ -0,0 +1,174 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../models/user_preferences.dart'; +import '../utils/debug_logger_io.dart'; + +/// Service for forwarding wardrive ping payloads to a user-configured +/// third-party API endpoint. Fire-and-forget: never blocks MeshMapper uploads. +/// +/// Payload sent to custom endpoint: +/// ```json +/// { "data": [ ...same ping objects as MeshMapper... ] } +/// ``` +/// +/// MeshMapper API key and session_id are NOT included. +/// Custom API key is sent as X-API-Key header. +class CustomApiService { + final http.Client _client; + final UserPreferences Function() _prefsGetter; + + /// Throttle map: error type → last time logged to user-facing error tab + final Map _errorThrottle = {}; + static const Duration _throttleWindow = Duration(minutes: 1); + static const Duration _requestTimeout = Duration(seconds: 10); + + /// Callback for user-facing error logging (wired to AppStateProvider.logError) + void Function(String message)? onError; + + /// Returns the 8-char public key prefix of the connected device, or null + String? Function()? contactGetter; + + /// Returns the current IATA zone code, or null if not in a zone + String? Function()? iataGetter; + + CustomApiService({ + required UserPreferences Function() prefsGetter, + http.Client? client, + }) : _prefsGetter = prefsGetter, + _client = client ?? http.Client(); + + /// Fire-and-forget: forward pings to the custom endpoint. + /// Called after successful MeshMapper upload (online queue or offline session). + /// Never throws. + void forwardPings( + List> pings, { + String? contactOverride, + String? iataOverride, + String source = 'online', + }) { + if (pings.isEmpty) return; + + final prefs = _prefsGetter(); + if (!prefs.customApiEnabled) return; + + debugLog( + '[CUSTOM API] Forward requested ($source): ${pings.length} item(s)'); + + if (prefs.customApiUrl == null || prefs.customApiUrl!.isEmpty) { + debugLog('[CUSTOM API] Forward skipped ($source): URL not configured'); + return; + } + if (prefs.customApiKey == null || prefs.customApiKey!.isEmpty) { + debugLog( + '[CUSTOM API] Forward skipped ($source): API key not configured'); + return; + } + + // Enrich with contact and iata (custom API only — never sent to MeshMapper) + final contact = prefs.customApiIncludeContact + ? (contactOverride ?? contactGetter?.call()) + : null; + final iata = iataOverride ?? iataGetter?.call(); + + // Log the endpoint host only — the full URL may embed auth tokens, and + // debug log files are uploaded verbatim with bug reports. + var endpointHost = Uri.tryParse(prefs.customApiUrl!)?.host ?? ''; + if (endpointHost.isEmpty) endpointHost = 'custom endpoint'; + + debugLog('[CUSTOM API] Forwarding ($source) → $endpointHost ' + '(contact=${contact ?? "off"}, iata=${iata ?? "none"})'); + + final enriched = pings.map((ping) { + final enrichedPing = Map.from(ping); + if (contact != null) enrichedPing['contact'] = contact; + if (iata != null) enrichedPing['iata'] = iata; + return enrichedPing; + }).toList(); + + // Fire and forget — do not await + _doForward(prefs.customApiUrl!, prefs.customApiKey!, enriched); + } + + Future _doForward( + String url, + String apiKey, + List> pings, + ) async { + final stopwatch = Stopwatch()..start(); + try { + final payload = json.encode({'data': pings}); + + final response = await _client + .post( + Uri.parse(url), + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': apiKey, + }, + body: payload, + ) + .timeout(_requestTimeout); + + stopwatch.stop(); + + if (response.statusCode >= 200 && response.statusCode < 300) { + debugLog( + '[CUSTOM API] Forward SUCCESS: ${pings.length} items in ${stopwatch.elapsedMilliseconds}ms'); + } else { + final errorType = 'http_${response.statusCode}'; + debugError( + '[CUSTOM API] Forward failed: HTTP ${response.statusCode} (${stopwatch.elapsedMilliseconds}ms)'); + debugError( + '[CUSTOM API] Body: ${response.body.length > 200 ? response.body.substring(0, 200) : response.body}'); + _throttledError( + errorType, 'Custom API returned HTTP ${response.statusCode}'); + } + } on TimeoutException { + stopwatch.stop(); + debugError( + '[CUSTOM API] Forward timed out after ${_requestTimeout.inSeconds}s'); + _throttledError('timeout', 'Custom API request timed out'); + } catch (e) { + stopwatch.stop(); + debugError('[CUSTOM API] Forward exception: $e'); + // Extract a concise, user-friendly message from the exception chain + final description = _describeError(e); + _throttledError('network_error', 'Custom API: $description'); + } + } + + /// Log error to user-facing error tab, throttled to one per error type per minute. + /// Debug logs (debugError) are always emitted unthrottled. + void _throttledError(String errorType, String message) { + final now = DateTime.now(); + final lastLog = _errorThrottle[errorType]; + if (lastLog != null && now.difference(lastLog) < _throttleWindow) { + return; // Suppressed — same error type logged within 1 minute + } + _errorThrottle[errorType] = now; + onError?.call(message); + } + + /// Extract a concise error description from an exception. + /// The http package wraps SocketException inside ClientException — + /// drill into the chain to surface the actionable detail (e.g. host lookup failure). + String _describeError(Object e) { + final full = e.toString(); + // Look for SocketException detail (e.g. "Failed host lookup: 'blah.blah'") + final socketMatch = + RegExp(r'SocketException: (.+?)(?:,|\()').firstMatch(full); + if (socketMatch != null) return socketMatch.group(1)!.trim(); + // Look for OS-level message + final osMatch = RegExp(r'OS Error: (.+?)(?:,|\))').firstMatch(full); + if (osMatch != null) return osMatch.group(1)!.trim(); + // Fallback: use the exception type + return e.runtimeType.toString(); + } + + void dispose() { + _errorThrottle.clear(); + } +} diff --git a/lib/services/debug_file_logger.dart b/lib/services/debug_file_logger.dart index 1406023c..940504cb 100644 --- a/lib/services/debug_file_logger.dart +++ b/lib/services/debug_file_logger.dart @@ -11,6 +11,7 @@ import 'package:path_provider/path_provider.dart'; /// - Non-persistent (always starts disabled on app launch) class DebugFileLogger { static const int maxLogFiles = 10; + /// Maximum file size for upload (4.5MB, 0.5MB safety margin under 5MB server limit) static const int maxUploadSizeBytes = 4718592; static File? _currentLogFile; @@ -87,6 +88,41 @@ class DebugFileLogger { } } + /// Credential shapes stripped from every line written to a log FILE. + static final RegExp _bearerPattern = + RegExp(r'Bearer\s+[A-Za-z0-9._~+/=-]{8,}'); + static final RegExp _tokenFieldPattern = + RegExp(r'"token"\s*:\s*"[0-9a-zA-Z._~+/=-]{8,}"'); + static final RegExp _codeParamPattern = RegExp( + r'\b(code|code_verifier|code_challenge|token)=[A-Za-z0-9._~%+/=-]{20,}'); + + /// The loose shapes the two patterns above miss: an UNQUOTED value, a `:` + /// separator, or a header name. `Map.toString()` renders a decoded body as + /// `{ok: true, token: fff…}` — no quotes, no `=` — and that is exactly what + /// `debug_submit_service.dart` writes on a failed upload. Case-insensitive so + /// the `X-MM-App-Token:` header is covered too. The 20-char floor keeps + /// `token: abc` and `code=7` intact. + static final RegExp _looseTokenPattern = RegExp( + r'''(\btoken['"]?\s*[:=]\s*)['"]?[A-Za-z0-9._~%+/=-]{20,}''', + caseSensitive: false); + + /// Strip credential shapes out of a log line. + /// + /// Log files are uploaded verbatim with bug reports and debug logging stays + /// on in release builds, so this is the last line of defence behind + /// "never log a secret". Public so it can be unit-tested. + static String scrubSecrets(String message) { + var out = message.replaceAll(_bearerPattern, 'Bearer '); + // Quoted JSON first, so the canonical `"token":""` shape is + // preserved rather than being half-eaten by the loose pattern. + out = out.replaceAll(_tokenFieldPattern, '"token":""'); + out = out.replaceAllMapped( + _codeParamPattern, (match) => '${match.group(1)}='); + out = out.replaceAllMapped( + _looseTokenPattern, (match) => '${match.group(1)}'); + return out; + } + /// Write a log entry to the current file /// /// Called by debug_logger_stub.dart for each log message @@ -98,7 +134,7 @@ class DebugFileLogger { if (!_enabled) return; final timestamp = DateTime.now().toIso8601String(); - final line = '[$timestamp] $level: $message'; + final line = '[$timestamp] $level: ${scrubSecrets(message)}'; if (_logSink == null) { // Buffer logs until sink is ready (race condition during initialization) @@ -293,7 +329,8 @@ class DebugFileLogger { for (final line in lines) { final lineBytes = line.length + 1; // +1 for newline - if (currentSize + lineBytes > maxUploadSizeBytes && currentChunk.isNotEmpty) { + if (currentSize + lineBytes > maxUploadSizeBytes && + currentChunk.isNotEmpty) { chunkLines.add(currentChunk); currentChunk = []; currentSize = 0; diff --git a/lib/services/debug_submit_service.dart b/lib/services/debug_submit_service.dart index fe902ba3..df147e74 100644 --- a/lib/services/debug_submit_service.dart +++ b/lib/services/debug_submit_service.dart @@ -135,7 +135,8 @@ class DebugSubmitService { ); if (ticketResult == null || ticketResult['success'] != true) { - final error = ticketResult?['message'] as String? ?? 'Failed to create ticket'; + final error = + ticketResult?['message'] as String? ?? 'Failed to create ticket'; debugError('[BUG REPORT] FAILED: Ticket creation failed: $error'); debugLog('[BUG REPORT] ========================================'); return BugReportResult.error(error); @@ -167,11 +168,13 @@ class DebugSubmitService { debugLog('[BUG REPORT] ----------------------------------------'); debugLog('[BUG REPORT] File ${i + 1}/$totalFiles: $filename'); - reportProgress('Uploading $filename...', fileProgress, currentFile: i + 1); + reportProgress('Uploading $filename...', fileProgress, + currentFile: i + 1); // Add delay before file uploads to prevent server overload if (totalFiles > 1) { - final delayMs = i == 0 ? 500 : 1000; // 500ms before first, 1s between others + final delayMs = + i == 0 ? 500 : 1000; // 500ms before first, 1s between others debugLog('[BUG REPORT] Waiting ${delayMs}ms before upload...'); await Future.delayed(Duration(milliseconds: delayMs)); } @@ -188,16 +191,20 @@ class DebugSubmitService { if (success) { uploadedCount++; debugLog('[BUG REPORT] File ${i + 1}/$totalFiles: SUCCESS'); - reportProgress('Uploaded $filename', fileProgress + progressPerFile, currentFile: i + 1); + reportProgress('Uploaded $filename', fileProgress + progressPerFile, + currentFile: i + 1); } else { failedCount++; debugError('[BUG REPORT] File ${i + 1}/$totalFiles: FAILED'); - reportProgress('Failed to upload $filename', fileProgress + progressPerFile, currentFile: i + 1); + reportProgress( + 'Failed to upload $filename', fileProgress + progressPerFile, + currentFile: i + 1); } } debugLog('[BUG REPORT] ----------------------------------------'); - debugLog('[BUG REPORT] Upload summary: $uploadedCount succeeded, $failedCount failed'); + debugLog( + '[BUG REPORT] Upload summary: $uploadedCount succeeded, $failedCount failed'); } reportProgress('Finalizing...', 0.95); @@ -205,7 +212,8 @@ class DebugSubmitService { debugLog('[BUG REPORT] ========================================'); debugLog('[BUG REPORT] Bug report submission complete'); debugLog('[BUG REPORT] Issue: #$issueNumber'); - debugLog('[BUG REPORT] Files: $uploadedCount uploaded, $failedCount failed'); + debugLog( + '[BUG REPORT] Files: $uploadedCount uploaded, $failedCount failed'); debugLog('[BUG REPORT] ========================================'); reportProgress('Complete!', 1.0); @@ -250,13 +258,15 @@ class DebugSubmitService { } // File was split into chunks - debugLog('[BUG REPORT] File $filename (${(fileSize / 1024 / 1024).toStringAsFixed(1)} MB) split into ${chunks.length} chunks'); + debugLog( + '[BUG REPORT] File $filename (${(fileSize / 1024 / 1024).toStringAsFixed(1)} MB) split into ${chunks.length} chunks'); bool allSucceeded = true; try { for (int i = 0; i < chunks.length; i++) { final chunkName = chunks[i].path.split('/').last; - debugLog('[BUG REPORT] Uploading chunk ${i + 1}/${chunks.length}: $chunkName'); + debugLog( + '[BUG REPORT] Uploading chunk ${i + 1}/${chunks.length}: $chunkName'); if (i > 0) { // Delay between chunk uploads @@ -274,11 +284,13 @@ class DebugSubmitService { ); if (!success) { - debugError('[BUG REPORT] Chunk ${i + 1}/${chunks.length} failed: $chunkName'); + debugError( + '[BUG REPORT] Chunk ${i + 1}/${chunks.length} failed: $chunkName'); allSucceeded = false; break; } - debugLog('[BUG REPORT] Chunk ${i + 1}/${chunks.length} uploaded successfully'); + debugLog( + '[BUG REPORT] Chunk ${i + 1}/${chunks.length} uploaded successfully'); } } finally { // Always clean up temp chunk files @@ -306,7 +318,8 @@ class DebugSubmitService { final fileHash = await computeFileHash(file); final fileSize = await file.length(); final fileSizeKb = (fileSize / 1024).toStringAsFixed(1); - debugLog('[BUG REPORT] File size: $fileSizeKb KB, Hash: ${fileHash.substring(0, 16)}...'); + debugLog( + '[BUG REPORT] File size: $fileSizeKb KB, Hash: ${fileHash.substring(0, 16)}...'); // Step 2: Request upload URL debugLog('[BUG REPORT] Step 2/4: Requesting upload URL...'); @@ -320,10 +333,12 @@ class DebugSubmitService { ); if (session == null) { - debugError('[BUG REPORT] FAILED: Could not get upload URL for: $filename'); + debugError( + '[BUG REPORT] FAILED: Could not get upload URL for: $filename'); return false; } - debugLog('[BUG REPORT] SUCCESS: Got upload session: ${session.sessionId}'); + debugLog( + '[BUG REPORT] SUCCESS: Got upload session: ${session.sessionId}'); // Step 3: Upload the file (with retry logic) debugLog('[BUG REPORT] Step 3/4: Uploading file data...'); @@ -343,19 +358,22 @@ class DebugSubmitService { if (attempt < maxRetries) { final delaySeconds = attempt * 2; // 2s, 4s backoff - debugWarn('[BUG REPORT] Upload attempt $attempt/$maxRetries failed, retrying in ${delaySeconds}s...'); + debugWarn( + '[BUG REPORT] Upload attempt $attempt/$maxRetries failed, retrying in ${delaySeconds}s...'); await Future.delayed(Duration(seconds: delaySeconds)); } } if (!uploadSuccess) { - debugError('[BUG REPORT] FAILED: File upload failed after $maxRetries attempts for: $filename'); + debugError( + '[BUG REPORT] FAILED: File upload failed after $maxRetries attempts for: $filename'); return false; } // Step 4: Complete the upload with GitHub issue reference debugLog('[BUG REPORT] Step 4/4: Confirming upload...'); - final userNotes = issueNumber != null ? 'GitHub Issue: $issueNumber' : null; + final userNotes = + issueNumber != null ? 'GitHub Issue: $issueNumber' : null; if (userNotes != null) { debugLog('[BUG REPORT] User notes: $userNotes'); } @@ -369,8 +387,10 @@ class DebugSubmitService { ); if (!completeSuccess) { - debugWarn('[BUG REPORT] WARNING: Upload confirmation failed for: $filename'); - debugWarn('[BUG REPORT] File was uploaded but confirmation failed - treating as success'); + debugWarn( + '[BUG REPORT] WARNING: Upload confirmation failed for: $filename'); + debugWarn( + '[BUG REPORT] File was uploaded but confirmation failed - treating as success'); } else { debugLog('[BUG REPORT] SUCCESS: Upload confirmed'); } @@ -407,20 +427,26 @@ class DebugSubmitService { debugLog('[BUG REPORT] body: ${body.length} chars'); final stopwatch = Stopwatch()..start(); - final response = await _client.post( - Uri.parse(url), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + Uri.parse(url), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); stopwatch.stop(); - debugLog('[BUG REPORT] Response received in ${stopwatch.elapsedMilliseconds}ms'); + debugLog( + '[BUG REPORT] Response received in ${stopwatch.elapsedMilliseconds}ms'); debugLog('[BUG REPORT] HTTP Status: ${response.statusCode}'); if (response.statusCode != 200) { debugError('[BUG REPORT] HTTP error: ${response.statusCode}'); debugError('[BUG REPORT] Response body: ${response.body}'); - return {'success': false, 'message': 'Server error: ${response.statusCode}'}; + return { + 'success': false, + 'message': 'Server error: ${response.statusCode}' + }; } final data = json.decode(response.body) as Map; @@ -466,21 +492,26 @@ class DebugSubmitService { debugLog('[BUG REPORT] POST $url'); debugLog('[BUG REPORT] Request payload:'); debugLog('[BUG REPORT] device_id: $deviceId'); - debugLog('[BUG REPORT] public_key: ${publicKey.length > 20 ? '${publicKey.substring(0, 20)}...' : publicKey}'); - debugLog('[BUG REPORT] file_size_bytes: $fileSizeBytes ($fileSizeKb KB)'); + debugLog( + '[BUG REPORT] public_key: ${publicKey.length > 20 ? '${publicKey.substring(0, 20)}...' : publicKey}'); + debugLog( + '[BUG REPORT] file_size_bytes: $fileSizeBytes ($fileSizeKb KB)'); debugLog('[BUG REPORT] file_hash: ${fileHash.substring(0, 16)}...'); debugLog('[BUG REPORT] app_version: $appVersion'); debugLog('[BUG REPORT] platform: $platform'); final stopwatch = Stopwatch()..start(); - final response = await _client.post( - Uri.parse(url), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + Uri.parse(url), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); stopwatch.stop(); - debugLog('[BUG REPORT] Response received in ${stopwatch.elapsedMilliseconds}ms'); + debugLog( + '[BUG REPORT] Response received in ${stopwatch.elapsedMilliseconds}ms'); debugLog('[BUG REPORT] HTTP Status: ${response.statusCode}'); if (response.statusCode != 200) { @@ -492,7 +523,8 @@ class DebugSubmitService { final data = json.decode(response.body) as Map; debugLog('[BUG REPORT] Response JSON:'); debugLog('[BUG REPORT] session_id: ${data['session_id']}'); - debugLog('[BUG REPORT] upload_url: ${data['upload_url'] != null ? '(present)' : '(missing)'}'); + debugLog( + '[BUG REPORT] upload_url: ${data['upload_url'] != null ? '(present)' : '(missing)'}'); debugLog('[BUG REPORT] expires_at: ${data['expires_at']}'); if (data['upload_url'] == null || data['session_id'] == null) { @@ -532,15 +564,19 @@ class DebugSubmitService { )); final stopwatch = Stopwatch()..start(); - final streamedResponse = await request.send().timeout(const Duration(seconds: 120)); + final streamedResponse = + await request.send().timeout(const Duration(seconds: 120)); final response = await http.Response.fromStream(streamedResponse); stopwatch.stop(); - final durationSec = (stopwatch.elapsedMilliseconds / 1000).toStringAsFixed(1); + final durationSec = + (stopwatch.elapsedMilliseconds / 1000).toStringAsFixed(1); final speedKbps = fileSize > 0 - ? ((fileSize / 1024) / (stopwatch.elapsedMilliseconds / 1000)).toStringAsFixed(1) + ? ((fileSize / 1024) / (stopwatch.elapsedMilliseconds / 1000)) + .toStringAsFixed(1) : '0'; - debugLog('[BUG REPORT] Upload completed in ${durationSec}s ($speedKbps KB/s)'); + debugLog( + '[BUG REPORT] Upload completed in ${durationSec}s ($speedKbps KB/s)'); debugLog('[BUG REPORT] HTTP Status: ${response.statusCode}'); if (response.statusCode != 200) { @@ -556,7 +592,8 @@ class DebugSubmitService { debugLog('[BUG REPORT] message: ${data['message']}'); } if (data['stored_hash'] != null) { - debugLog('[BUG REPORT] stored_hash: ${data['stored_hash'].toString().substring(0, 16)}...'); + debugLog( + '[BUG REPORT] stored_hash: ${data['stored_hash'].toString().substring(0, 16)}...'); } final success = data['success'] == true; @@ -598,14 +635,17 @@ class DebugSubmitService { } final stopwatch = Stopwatch()..start(); - final response = await _client.post( - Uri.parse(url), - headers: {'Content-Type': 'application/json'}, - body: json.encode(payload), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + Uri.parse(url), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 30)); stopwatch.stop(); - debugLog('[BUG REPORT] Response received in ${stopwatch.elapsedMilliseconds}ms'); + debugLog( + '[BUG REPORT] Response received in ${stopwatch.elapsedMilliseconds}ms'); debugLog('[BUG REPORT] HTTP Status: ${response.statusCode}'); if (response.statusCode != 200) { @@ -658,7 +698,8 @@ class DebugSubmitService { if (isChunked) { final fileSize = await file.length(); - debugLog('[DEBUG UPLOAD] File (${(fileSize / 1024 / 1024).toStringAsFixed(1)} MB) split into $totalChunks chunks'); + debugLog( + '[DEBUG UPLOAD] File (${(fileSize / 1024 / 1024).toStringAsFixed(1)} MB) split into $totalChunks chunks'); } // Progress range: 0.1 to 0.9 divided across chunks @@ -676,9 +717,11 @@ class DebugSubmitService { } void reportChunkProgress(String status, double chunkProgress) { - final overallProgress = chunkBase + (chunkProgress * progressPerChunk); + final overallProgress = + chunkBase + (chunkProgress * progressPerChunk); onProgress?.call(BugReportProgress( - status: isChunked ? '$status (part ${i + 1}/$totalChunks)' : status, + status: + isChunked ? '$status (part ${i + 1}/$totalChunks)' : status, progress: overallProgress.clamp(0.0, 1.0), currentFile: isChunked ? i + 1 : 1, totalFiles: isChunked ? totalChunks : 1, @@ -697,7 +740,8 @@ class DebugSubmitService { final fileHash = await computeFileHash(chunk); final chunkSize = await chunk.length(); final chunkSizeKb = (chunkSize / 1024).toStringAsFixed(1); - debugLog('[DEBUG UPLOAD] Chunk size: $chunkSizeKb KB, Hash: ${fileHash.substring(0, 16)}...'); + debugLog( + '[DEBUG UPLOAD] Chunk size: $chunkSizeKb KB, Hash: ${fileHash.substring(0, 16)}...'); // Step 2: Request upload URL reportChunkProgress('Requesting upload...', 0.2); @@ -712,7 +756,8 @@ class DebugSubmitService { ); if (session == null) { - debugError('[DEBUG UPLOAD] FAILED: Could not get upload URL for $chunkName'); + debugError( + '[DEBUG UPLOAD] FAILED: Could not get upload URL for $chunkName'); allSucceeded = false; break; } @@ -737,13 +782,15 @@ class DebugSubmitService { if (attempt < maxRetries) { final delaySeconds = attempt * 2; - debugWarn('[DEBUG UPLOAD] Upload attempt $attempt/$maxRetries failed, retrying in ${delaySeconds}s...'); + debugWarn( + '[DEBUG UPLOAD] Upload attempt $attempt/$maxRetries failed, retrying in ${delaySeconds}s...'); await Future.delayed(Duration(seconds: delaySeconds)); } } if (!uploadSuccess) { - debugError('[DEBUG UPLOAD] FAILED: Upload failed after $maxRetries attempts for $chunkName'); + debugError( + '[DEBUG UPLOAD] FAILED: Upload failed after $maxRetries attempts for $chunkName'); allSucceeded = false; break; } @@ -760,7 +807,8 @@ class DebugSubmitService { ); if (!completeSuccess) { - debugWarn('[DEBUG UPLOAD] Confirmation failed but file was uploaded'); + debugWarn( + '[DEBUG UPLOAD] Confirmation failed but file was uploaded'); } debugLog('[DEBUG UPLOAD] Chunk ${i + 1}/$totalChunks complete'); @@ -781,7 +829,8 @@ class DebugSubmitService { totalFiles: totalChunks, )); debugLog('[DEBUG UPLOAD] ========================================'); - debugLog('[DEBUG UPLOAD] Upload complete: $filename${isChunked ? ' ($totalChunks chunks)' : ''}'); + debugLog( + '[DEBUG UPLOAD] Upload complete: $filename${isChunked ? ' ($totalChunks chunks)' : ''}'); debugLog('[DEBUG UPLOAD] ========================================'); } else { debugLog('[DEBUG UPLOAD] ========================================'); diff --git a/lib/services/device_model_service.dart b/lib/services/device_model_service.dart index c9aecb2a..6e9f9aac 100644 --- a/lib/services/device_model_service.dart +++ b/lib/services/device_model_service.dart @@ -6,7 +6,7 @@ import '../models/device_model.dart'; /// Device model service for auto-power selection /// Ported from parseDeviceModel() and autoSetPowerLevel() in wardrive.js -/// +/// /// CRITICAL: Correct power configuration is essential for PA amplifier models /// to prevent hardware damage. class DeviceModelService { @@ -24,9 +24,10 @@ class DeviceModelService { if (_isLoaded) return; try { - final jsonString = await rootBundle.loadString('assets/device-models.json'); + final jsonString = + await rootBundle.loadString('assets/device-models.json'); final jsonData = json.decode(jsonString) as Map; - + final database = DeviceModelsDatabase.fromJson(jsonData); _models = database.devices; _isLoaded = true; @@ -39,7 +40,7 @@ class DeviceModelService { /// Match device manufacturer string to known model /// Reference: parseDeviceModel() in wardrive.js - /// + /// /// Strips build suffix (e.g., "nightly-e31c46f") and matches against database DeviceModel? matchDevice(String manufacturerString) { if (_models.isEmpty) return null; @@ -68,7 +69,7 @@ class DeviceModelService { final parts = cleanManufacturer.split(RegExp(r'[\s\-_()]+')); for (final model in _models) { final modelParts = model.manufacturer.split(RegExp(r'[\s\-_()]+')); - + // Check if key identifying parts match int matchCount = 0; for (final modelPart in modelParts) { @@ -76,7 +77,7 @@ class DeviceModelService { matchCount++; } } - + // Require at least 2 matching parts if (matchCount >= 2) { return model; diff --git a/lib/services/external_commands/external_command_models.dart b/lib/services/external_commands/external_command_models.dart new file mode 100644 index 00000000..ed84a400 --- /dev/null +++ b/lib/services/external_commands/external_command_models.dart @@ -0,0 +1,352 @@ +import 'package:flutter/foundation.dart'; + +enum ExternalCommandSource { + watch, + siri, + androidAuto; + + static ExternalCommandSource? fromWire(String value) { + for (final source in values) { + if (source.name == value) return source; + } + return null; + } +} + +enum ExternalSessionCommandKind { + startSession, + stopSession, + manualPing; + + static ExternalSessionCommandKind? fromWire(String value) { + for (final kind in values) { + if (kind.name == value) return kind; + } + return null; + } +} + +/// The generic session modes an external surface may request. +/// +/// Trace is intentionally absent: it needs a stable repeater identity and a +/// dedicated command rather than a string smuggled through generic Start. +enum ExternalSessionMode { + active, + passive, + hybrid; + + static ExternalSessionMode? fromWire(String value) { + for (final mode in values) { + if (mode.name == value) return mode; + } + return null; + } + + String get displayName => switch (this) { + ExternalSessionMode.active => 'Active', + ExternalSessionMode.passive => 'Passive Discovery', + ExternalSessionMode.hybrid => 'Hybrid', + }; +} + +enum ExternalCommandDisposition { + admitted, + noOp, + refused; +} + +/// Stable refusal identities shared by compact and spoken presentations. +enum ExternalCommandReasonCode { + notConnected, + notConnectedToDevice, + stillStopping, + traceSessionActive, + alreadyStarting, + selectAntennaOption, + selectAntennaOptionBeforePinging, + selectPowerLevel, + selectPowerLevelUnknownDevice, + offlineMode, + passiveOnly, + zoneAtCapacity, + floodTrafficOff, + coolingDown, + waitFiveSeconds, + waitFifteenSeconds, + pingInProgress, + listeningForPingResponse, + waitingForGpsLock, + gpsDataStale, + gpsAccuracyLow, + anotherOperationInProgress, + stillStartingTryStopAgain, + sessionAlreadyEnded, + couldNotStart, + couldNotStop, + pingFailed, + appClosing, + commandExpired, + noRememberedCompanion, + alreadyConnected, + anotherCompanionConnected, + alreadyConnecting, + userInteractionRequired, + other, +} + +/// A typed reason with the exact compact text rendered by small surfaces. +/// +/// [ExternalCommandReasonCode.other] preserves success text and free-form +/// provider/server failures without making those deeper layers adopt this enum. +@immutable +class ExternalCommandReason { + const ExternalCommandReason.known(this.code) + : assert(code != ExternalCommandReasonCode.other), + rawText = null; + + const ExternalCommandReason.other(this.rawText) + : code = ExternalCommandReasonCode.other; + + static const notConnected = + ExternalCommandReason.known(ExternalCommandReasonCode.notConnected); + static const notConnectedToDevice = ExternalCommandReason.known( + ExternalCommandReasonCode.notConnectedToDevice, + ); + static const stillStopping = + ExternalCommandReason.known(ExternalCommandReasonCode.stillStopping); + static const traceSessionActive = ExternalCommandReason.known( + ExternalCommandReasonCode.traceSessionActive, + ); + static const alreadyStarting = + ExternalCommandReason.known(ExternalCommandReasonCode.alreadyStarting); + static const selectAntennaOption = ExternalCommandReason.known( + ExternalCommandReasonCode.selectAntennaOption, + ); + static const selectAntennaOptionBeforePinging = ExternalCommandReason.known( + ExternalCommandReasonCode.selectAntennaOptionBeforePinging, + ); + static const selectPowerLevel = + ExternalCommandReason.known(ExternalCommandReasonCode.selectPowerLevel); + static const selectPowerLevelUnknownDevice = ExternalCommandReason.known( + ExternalCommandReasonCode.selectPowerLevelUnknownDevice, + ); + static const offlineMode = + ExternalCommandReason.known(ExternalCommandReasonCode.offlineMode); + static const passiveOnly = + ExternalCommandReason.known(ExternalCommandReasonCode.passiveOnly); + static const zoneAtCapacity = + ExternalCommandReason.known(ExternalCommandReasonCode.zoneAtCapacity); + static const floodTrafficOff = + ExternalCommandReason.known(ExternalCommandReasonCode.floodTrafficOff); + static const coolingDown = + ExternalCommandReason.known(ExternalCommandReasonCode.coolingDown); + static const waitFiveSeconds = + ExternalCommandReason.known(ExternalCommandReasonCode.waitFiveSeconds); + static const waitFifteenSeconds = ExternalCommandReason.known( + ExternalCommandReasonCode.waitFifteenSeconds, + ); + static const pingInProgress = + ExternalCommandReason.known(ExternalCommandReasonCode.pingInProgress); + static const listeningForPingResponse = ExternalCommandReason.known( + ExternalCommandReasonCode.listeningForPingResponse, + ); + static const waitingForGpsLock = ExternalCommandReason.known( + ExternalCommandReasonCode.waitingForGpsLock, + ); + static const gpsDataStale = + ExternalCommandReason.known(ExternalCommandReasonCode.gpsDataStale); + static const gpsAccuracyLow = + ExternalCommandReason.known(ExternalCommandReasonCode.gpsAccuracyLow); + static const anotherOperationInProgress = ExternalCommandReason.known( + ExternalCommandReasonCode.anotherOperationInProgress, + ); + static const stillStartingTryStopAgain = ExternalCommandReason.known( + ExternalCommandReasonCode.stillStartingTryStopAgain, + ); + static const sessionAlreadyEnded = ExternalCommandReason.known( + ExternalCommandReasonCode.sessionAlreadyEnded, + ); + static const couldNotStart = + ExternalCommandReason.known(ExternalCommandReasonCode.couldNotStart); + static const couldNotStop = + ExternalCommandReason.known(ExternalCommandReasonCode.couldNotStop); + static const pingFailed = + ExternalCommandReason.known(ExternalCommandReasonCode.pingFailed); + static const appClosing = + ExternalCommandReason.known(ExternalCommandReasonCode.appClosing); + static const commandExpired = + ExternalCommandReason.known(ExternalCommandReasonCode.commandExpired); + static const noRememberedCompanion = ExternalCommandReason.known( + ExternalCommandReasonCode.noRememberedCompanion, + ); + static const alreadyConnected = + ExternalCommandReason.known(ExternalCommandReasonCode.alreadyConnected); + static const anotherCompanionConnected = ExternalCommandReason.known( + ExternalCommandReasonCode.anotherCompanionConnected, + ); + static const alreadyConnecting = + ExternalCommandReason.known(ExternalCommandReasonCode.alreadyConnecting); + static const userInteractionRequired = ExternalCommandReason.known( + ExternalCommandReasonCode.userInteractionRequired, + ); + + final ExternalCommandReasonCode code; + final String? rawText; + + String get compactText => switch (code) { + ExternalCommandReasonCode.notConnected => 'Not connected', + ExternalCommandReasonCode.notConnectedToDevice => + 'Not connected to device', + ExternalCommandReasonCode.stillStopping => 'Still stopping', + ExternalCommandReasonCode.traceSessionActive => 'Trace session active', + ExternalCommandReasonCode.alreadyStarting => 'Already starting', + ExternalCommandReasonCode.selectAntennaOption => + 'Select antenna option', + ExternalCommandReasonCode.selectAntennaOptionBeforePinging => + 'Select antenna option before pinging', + ExternalCommandReasonCode.selectPowerLevel => 'Select power level', + ExternalCommandReasonCode.selectPowerLevelUnknownDevice => + 'Select power level (unknown device)', + ExternalCommandReasonCode.offlineMode => 'Offline Mode', + ExternalCommandReasonCode.passiveOnly => 'Passive Only', + ExternalCommandReasonCode.zoneAtCapacity => + 'Zone at TX capacity (Passive Only)', + ExternalCommandReasonCode.floodTrafficOff => 'Flood Traffic Off', + ExternalCommandReasonCode.coolingDown => 'Cooling down', + ExternalCommandReasonCode.waitFiveSeconds => + 'Wait 5 seconds between pings', + ExternalCommandReasonCode.waitFifteenSeconds => + 'Wait 15 seconds between manual pings', + ExternalCommandReasonCode.pingInProgress => 'Ping in progress', + ExternalCommandReasonCode.listeningForPingResponse => + 'Listening for ping response', + ExternalCommandReasonCode.waitingForGpsLock => 'Waiting for GPS lock', + ExternalCommandReasonCode.gpsDataStale => + 'GPS data too old (> 60 seconds)', + ExternalCommandReasonCode.gpsAccuracyLow => + 'GPS accuracy too low (> 100 meters)', + ExternalCommandReasonCode.anotherOperationInProgress => + 'Another operation is in progress', + ExternalCommandReasonCode.stillStartingTryStopAgain => + 'Still starting, try Stop again', + ExternalCommandReasonCode.sessionAlreadyEnded => + 'That session already ended', + ExternalCommandReasonCode.couldNotStart => 'Could not start', + ExternalCommandReasonCode.couldNotStop => 'Could not stop', + ExternalCommandReasonCode.pingFailed => 'Ping failed', + ExternalCommandReasonCode.appClosing => 'App closing', + ExternalCommandReasonCode.commandExpired => + 'Command arrived too late to run safely', + ExternalCommandReasonCode.noRememberedCompanion => + 'No remembered companion', + ExternalCommandReasonCode.alreadyConnected => 'Already connected', + ExternalCommandReasonCode.anotherCompanionConnected => + 'Another companion is connected', + ExternalCommandReasonCode.alreadyConnecting => 'Already connecting', + ExternalCommandReasonCode.userInteractionRequired => + 'User interaction required', + ExternalCommandReasonCode.other => rawText ?? '', + }; +} + +@immutable +class ExternalSessionCommand { + const ExternalSessionCommand({ + required this.id, + required this.source, + required this.kind, + required this.issuedAt, + this.expiresAt, + this.mode, + this.sessionId, + }); + + final String id; + final ExternalCommandSource source; + final ExternalSessionCommandKind kind; + final DateTime issuedAt; + + /// The instant the issuing surface stops waiting for a real outcome. + /// + /// Siri resumes its App Intent with a failure once this passes, so the phone + /// must stop too rather than committing to the radio behind a person who was + /// already told it did not happen. Surfaces that wait indefinitely, such as + /// the watch, leave this null and fall back to [maximumExternalCommandAge]. + final DateTime? expiresAt; + final String? mode; + final String? sessionId; + + static ExternalSessionCommand? fromMap( + Map map, { + required ExternalCommandSource expectedSource, + }) { + final id = map['id']; + final source = map['source']; + final kind = map['kind']; + final issuedAtMs = map['issuedAtMs']; + if (id is! String || + id.isEmpty || + source is! String || + ExternalCommandSource.fromWire(source) != expectedSource || + kind is! String || + issuedAtMs is! num) { + return null; + } + final parsedKind = ExternalSessionCommandKind.fromWire(kind); + if (parsedKind == null) return null; + final mode = map['mode']; + final sessionId = map['sessionId']; + if (mode != null && mode is! String) return null; + if (sessionId != null && sessionId is! String) return null; + + return ExternalSessionCommand( + id: id, + source: expectedSource, + kind: parsedKind, + issuedAt: DateTime.fromMillisecondsSinceEpoch(issuedAtMs.toInt()), + mode: mode as String?, + sessionId: sessionId as String?, + ); + } +} + +@immutable +class ExternalCommandAdmission { + const ExternalCommandAdmission({ + required this.disposition, + this.reason, + this.mode, + }); + + final ExternalCommandDisposition disposition; + final ExternalCommandReason? reason; + final ExternalSessionMode? mode; + + bool get shouldExecute => disposition == ExternalCommandDisposition.admitted; +} + +@immutable +class ExternalCommandCompletion { + const ExternalCommandCompletion({ + required this.success, + required this.disposition, + this.message, + this.sessionId, + this.mode, + }); + + final bool success; + final ExternalCommandDisposition disposition; + final ExternalCommandReason? message; + final String? sessionId; + final ExternalSessionMode? mode; + + Map toMap({String? commandId}) => { + if (commandId != null) 'id': commandId, + 'success': success, + 'disposition': disposition.name, + 'message': message?.compactText, + 'sessionId': sessionId, + 'mode': mode?.name, + }; +} diff --git a/lib/services/external_commands/external_session_commands.dart b/lib/services/external_commands/external_session_commands.dart new file mode 100644 index 00000000..d77ea110 --- /dev/null +++ b/lib/services/external_commands/external_session_commands.dart @@ -0,0 +1,271 @@ +import 'external_command_models.dart'; + +const Duration maximumExternalCommandAge = Duration(seconds: 30); +const Duration externalCommandFutureTolerance = Duration(seconds: 5); + +/// Headroom reserved at the last checkpoint before an irreversible side effect. +/// +/// A deadline that has not quite passed is no use if the remaining work +/// outlives it: the surface would still have given up before the radio came up. +/// Refusing while this much is left keeps the two in the same order. +const Duration externalCommandCommitMargin = Duration(seconds: 2); + +String get externalCommandExpiredReason => + ExternalCommandReason.commandExpired.compactText; + +/// Spoken form of [ExternalCommandReasonCode.commandExpired]. +/// +/// The compact label stays terse for the watch; a voice surface has room to say +/// what to do next. Connect builds its own responses, so it reads this too +/// rather than growing a fourth copy of the sentence. +const String externalCommandExpiredVoiceMessage = + 'That request took too long to reach MeshMapper. Try again.'; + +/// Refuses a command whose issuing surface has already stopped waiting. +ExternalCommandReason? externalCommandDeadlineRefusal( + DateTime? expiresAt, { + DateTime? now, +}) { + if (expiresAt == null) return null; + return (now ?? DateTime.now()).isBefore(expiresAt) + ? null + : ExternalCommandReason.commandExpired; +} + +/// Whether too little of the deadline is left to reach the radio in time. +/// +/// Admission runs before the awaited session check; this runs after it, at the +/// last point where refusing is still free. +bool externalCommandCannotCommit(DateTime? expiresAt, {DateTime? now}) { + if (expiresAt == null) return false; + return !(now ?? DateTime.now()) + .add(externalCommandCommitMargin) + .isBefore(expiresAt); +} + +ExternalCommandReason? externalCommandTimestampRefusal( + DateTime issuedAt, { + DateTime? now, +}) { + final age = (now ?? DateTime.now()).difference(issuedAt); + if (age > maximumExternalCommandAge || + age < -externalCommandFutureTolerance) { + return ExternalCommandReason.commandExpired; + } + return null; +} + +/// Reject commands whose delayed execution could transmit from the wrong +/// location. Stop is deliberately exempt and is instead bound to a session ID. +ExternalCommandReason? externalCommandAgeRefusal( + ExternalSessionCommand command, { + DateTime? now, +}) { + if (command.kind == ExternalSessionCommandKind.stopSession) return null; + return externalCommandDeadlineRefusal(command.expiresAt, now: now) ?? + externalCommandTimestampRefusal(command.issuedAt, now: now); +} + +/// Expands compact admission labels into complete, actionable voice responses. +/// +/// Watch surfaces intentionally retain the short reason stored on the shared +/// admission result. Siri calls this only at its presentation boundary, so the +/// policy decision stays transport-neutral while a spoken refusal explains +/// what happened and, where possible, what the person can do next. +ExternalCommandCompletion externalCommandCompletionForVoice({ + required ExternalSessionCommand command, + required ExternalCommandCompletion completion, +}) { + final message = _externalCommandVoiceMessage( + command: command, + reason: completion.message, + ); + if (message == completion.message?.compactText) return completion; + return ExternalCommandCompletion( + success: completion.success, + disposition: completion.disposition, + message: ExternalCommandReason.other(message), + sessionId: completion.sessionId, + mode: completion.mode, + ); +} + +String _externalCommandVoiceMessage({ + required ExternalSessionCommand command, + required ExternalCommandReason? reason, +}) { + final compact = reason?.compactText.trim(); + if (compact == null || compact.isEmpty) { + return switch (command.kind) { + ExternalSessionCommandKind.startSession => + "MeshMapper couldn't start the requested session.", + ExternalSessionCommandKind.stopSession => + "MeshMapper couldn't stop the current session.", + ExternalSessionCommandKind.manualPing => + "MeshMapper couldn't send the manual ping.", + }; + } + + final mode = ExternalSessionMode.fromWire(command.mode ?? ''); + final modeName = mode?.displayName ?? 'requested'; + final isStart = command.kind == ExternalSessionCommandKind.startSession; + + return switch (reason!.code) { + ExternalCommandReasonCode.notConnected || + ExternalCommandReasonCode.notConnectedToDevice => + "MeshMapper isn't connected. Reconnect it, then try again.", + ExternalCommandReasonCode.stillStopping => + 'MeshMapper is still stopping. Try again shortly.', + ExternalCommandReasonCode.traceSessionActive => + 'Stop the active Trace session before starting $modeName mode.', + ExternalCommandReasonCode.alreadyStarting => + 'MeshMapper is already starting a session.', + ExternalCommandReasonCode.selectAntennaOption || + ExternalCommandReasonCode.selectAntennaOptionBeforePinging => + 'Select an antenna option in MeshMapper, then try again.', + ExternalCommandReasonCode.selectPowerLevel || + ExternalCommandReasonCode.selectPowerLevelUnknownDevice => + 'Select a power level in MeshMapper, then try again.', + ExternalCommandReasonCode.offlineMode => isStart + ? "$modeName mode isn't available in Offline Mode. Start Passive Discovery instead." + : "Manual pings aren't available in Offline Mode. Turn it off and try again.", + ExternalCommandReasonCode.passiveOnly || + ExternalCommandReasonCode.zoneAtCapacity => + isStart + ? "$modeName mode isn't available in this region. Start Passive Discovery instead." + : "Manual pings aren't available in this region. Passive Discovery still works.", + ExternalCommandReasonCode.floodTrafficOff => isStart + ? '$modeName mode requires Flood Traffic. Enable it or start Passive Discovery.' + : 'Manual pings require Flood Traffic. Enable it and try again.', + ExternalCommandReasonCode.coolingDown || + ExternalCommandReasonCode.waitFiveSeconds || + ExternalCommandReasonCode.waitFifteenSeconds => + 'MeshMapper is cooling down. Try again shortly.', + ExternalCommandReasonCode.pingInProgress => + 'A ping is already in progress. Try again when it finishes.', + ExternalCommandReasonCode.listeningForPingResponse => + 'MeshMapper is listening for a response. Try again shortly.', + ExternalCommandReasonCode.waitingForGpsLock => + 'MeshMapper is waiting for a GPS fix. Try again shortly.', + ExternalCommandReasonCode.gpsDataStale => + 'MeshMapper needs a current GPS position. Let location refresh, then retry.', + ExternalCommandReasonCode.gpsAccuracyLow => + 'GPS accuracy is too low. Try again when it improves.', + ExternalCommandReasonCode.anotherOperationInProgress => + 'Another radio operation is in progress. Try again shortly.', + ExternalCommandReasonCode.stillStartingTryStopAgain => + 'MeshMapper is still starting. Try Stop again shortly.', + ExternalCommandReasonCode.sessionAlreadyEnded => + 'That session already ended. There is nothing to stop.', + ExternalCommandReasonCode.couldNotStart => + "MeshMapper couldn't start $modeName mode.", + ExternalCommandReasonCode.couldNotStop => + "MeshMapper couldn't stop. Check the app for details.", + ExternalCommandReasonCode.pingFailed => + "MeshMapper couldn't send the manual ping. Open the app for details.", + ExternalCommandReasonCode.appClosing => + 'MeshMapper is closing. Open it and try again.', + ExternalCommandReasonCode.commandExpired => + externalCommandExpiredVoiceMessage, + ExternalCommandReasonCode.noRememberedCompanion || + ExternalCommandReasonCode.alreadyConnected || + ExternalCommandReasonCode.anotherCompanionConnected || + ExternalCommandReasonCode.alreadyConnecting || + ExternalCommandReasonCode.userInteractionRequired || + ExternalCommandReasonCode.other => + compact, + }; +} + +/// [currentMode] is the wire name of the running mode and is what the stop path +/// resolves back into an [ExternalSessionMode]. [currentModeLabel] is the name +/// a person hears, which the app spells differently (Trace, not targeted). It +/// is required: the wire name is not speakable, so falling back to it would +/// have put the enum spelling in a spoken sentence. +ExternalCommandAdmission resolveExternalSessionTransition({ + required ExternalSessionCommand command, + required bool isSessionActive, + required bool isSessionStarting, + required String currentMode, + required String currentSessionId, + required String currentModeLabel, + DateTime? now, +}) { + final ageRefusal = externalCommandAgeRefusal(command, now: now); + if (ageRefusal != null) { + return ExternalCommandAdmission( + disposition: ExternalCommandDisposition.refused, + reason: ageRefusal, + ); + } + + switch (command.kind) { + case ExternalSessionCommandKind.startSession: + if (isSessionActive) { + return ExternalCommandAdmission( + disposition: ExternalCommandDisposition.noOp, + reason: ExternalCommandReason.other( + 'MeshMapper is already running in $currentModeLabel mode.', + ), + ); + } + if (isSessionStarting) { + return const ExternalCommandAdmission( + disposition: ExternalCommandDisposition.noOp, + reason: ExternalCommandReason.other( + 'MeshMapper is already starting.', + ), + ); + } + final rawMode = command.mode ?? + (command.source == ExternalCommandSource.siri ? 'passive' : null); + if (rawMode == null) { + return const ExternalCommandAdmission( + disposition: ExternalCommandDisposition.refused, + reason: ExternalCommandReason.other('No start mode was provided'), + ); + } + final mode = ExternalSessionMode.fromWire(rawMode); + if (mode == null) { + return const ExternalCommandAdmission( + disposition: ExternalCommandDisposition.refused, + reason: ExternalCommandReason.other('Unsupported start mode'), + ); + } + return ExternalCommandAdmission( + disposition: ExternalCommandDisposition.admitted, + mode: mode, + ); + + case ExternalSessionCommandKind.stopSession: + if (isSessionStarting && !isSessionActive) { + return const ExternalCommandAdmission( + disposition: ExternalCommandDisposition.refused, + reason: ExternalCommandReason.stillStartingTryStopAgain, + ); + } + if (!isSessionActive) { + return const ExternalCommandAdmission( + disposition: ExternalCommandDisposition.noOp, + reason: ExternalCommandReason.other( + "There isn't a MeshMapper session running.", + ), + ); + } + if (command.sessionId != null && command.sessionId != currentSessionId) { + return const ExternalCommandAdmission( + disposition: ExternalCommandDisposition.refused, + reason: ExternalCommandReason.sessionAlreadyEnded, + ); + } + return ExternalCommandAdmission( + disposition: ExternalCommandDisposition.admitted, + mode: ExternalSessionMode.fromWire(currentMode.toLowerCase()), + ); + + case ExternalSessionCommandKind.manualPing: + return const ExternalCommandAdmission( + disposition: ExternalCommandDisposition.admitted, + ); + } +} diff --git a/lib/services/external_surfaces/external_surface_color.dart b/lib/services/external_surfaces/external_surface_color.dart new file mode 100644 index 00000000..a889148a --- /dev/null +++ b/lib/services/external_surfaces/external_surface_color.dart @@ -0,0 +1,32 @@ +import 'dart:ui' show Color; + +/// An sRGB colour resolved from the active colour-vision palette. +/// +/// Resolving on the phone is deliberate: Dart owns the palettes, while native +/// glance surfaces receive the same small RGB value and never need to duplicate +/// accessibility policy. +class ExternalSurfaceColor { + const ExternalSurfaceColor(this.r, this.g, this.b); + + factory ExternalSurfaceColor.fromColor(Color color) => ExternalSurfaceColor( + (color.r * 255.0).roundToDouble() / 255.0, + (color.g * 255.0).roundToDouble() / 255.0, + (color.b * 255.0).roundToDouble() / 255.0, + ); + + final double r; + final double g; + final double b; + + Map toMap() => {'r': r, 'g': g, 'b': b}; + + @override + bool operator ==(Object other) => + other is ExternalSurfaceColor && + other.r == r && + other.g == g && + other.b == b; + + @override + int get hashCode => Object.hash(r, g, b); +} diff --git a/lib/services/external_surfaces/external_surface_publisher.dart b/lib/services/external_surfaces/external_surface_publisher.dart new file mode 100644 index 00000000..172da170 --- /dev/null +++ b/lib/services/external_surfaces/external_surface_publisher.dart @@ -0,0 +1,352 @@ +import 'dart:async'; + +enum ExternalSurfacePreflightPolicy { + deduplicateAfterPublish, + throttleAgainstPublishedUrgency, + throttleAgainstLastBuild, +} + +enum ExternalSurfacePublishDisposition { + published, + rejected, + disable, +} + +class ExternalSurfacePublishResult { + const ExternalSurfacePublishResult._( + this.disposition, { + this.retryAfter, + }); + + const ExternalSurfacePublishResult.published() + : this._(ExternalSurfacePublishDisposition.published); + + const ExternalSurfacePublishResult.rejected({Duration? retryAfter}) + : this._( + ExternalSurfacePublishDisposition.rejected, + retryAfter: retryAfter, + ); + + const ExternalSurfacePublishResult.disable() + : this._(ExternalSurfacePublishDisposition.disable); + + final ExternalSurfacePublishDisposition disposition; + final Duration? retryAfter; +} + +class ExternalSurfacePublication { + const ExternalSurfacePublication({ + required this.snapshot, + required this.payload, + required this.fingerprint, + required this.urgent, + required this.forceDelivery, + }); + + final TSnapshot snapshot; + final TPayload payload; + final String fingerprint; + final bool urgent; + final bool forceDelivery; +} + +typedef ExternalSurfaceSnapshotBuilder = TSnapshot? Function(); +typedef ExternalSurfacePreflightKeyBuilder = Object? Function(); +typedef ExternalSurfacePayloadBuilder = TPayload Function( + TSnapshot snapshot, +); +typedef ExternalSurfaceFingerprintBuilder = String Function( + TPayload payload, +); +typedef ExternalSurfaceUrgencyKeyBuilder = Object? Function( + TSnapshot snapshot, +); +typedef ExternalSurfacePublish + = Future Function( + ExternalSurfacePublication publication, +); +typedef ExternalSurfaceClear = Future Function({ + required bool immediate, +}); +typedef ExternalSurfaceCandidateGate = bool Function( + ExternalSurfacePublication publication, +); + +/// Shared debounce, preflight, dedupe, throttle, and serialization pipeline for +/// state projected onto an external surface. +/// +/// The owner supplies domain serialization and transport callbacks. This class +/// deliberately knows nothing about WatchConnectivity, ActivityKit, App Groups, +/// or the shape of their payloads. +class ExternalSurfacePublisher { + ExternalSurfacePublisher({ + required Duration debounceDelay, + required Duration minimumNonUrgentInterval, + required bool Function() isEnabled, + required ExternalSurfacePreflightPolicy preflightPolicy, + required ExternalSurfacePayloadBuilder payloadBuilder, + required ExternalSurfaceFingerprintBuilder fingerprintBuilder, + required ExternalSurfaceUrgencyKeyBuilder urgencyKeyBuilder, + required ExternalSurfacePublish publish, + ExternalSurfaceClear? clear, + ExternalSurfaceCandidateGate? candidateGate, + void Function(Object error)? onQueueError, + bool restartDebounce = true, + bool immediateBypassesMinimumInterval = false, + }) : _debounceDelay = debounceDelay, + _minimumNonUrgentInterval = minimumNonUrgentInterval, + _isEnabled = isEnabled, + _preflightPolicy = preflightPolicy, + _payloadBuilder = payloadBuilder, + _fingerprintBuilder = fingerprintBuilder, + _urgencyKeyBuilder = urgencyKeyBuilder, + _publish = publish, + _clear = clear, + _candidateGate = candidateGate, + _onQueueError = onQueueError, + _restartDebounce = restartDebounce, + _immediateBypassesMinimumInterval = immediateBypassesMinimumInterval; + + final Duration _debounceDelay; + final Duration _minimumNonUrgentInterval; + final bool Function() _isEnabled; + final ExternalSurfacePreflightPolicy _preflightPolicy; + final ExternalSurfacePayloadBuilder _payloadBuilder; + final ExternalSurfaceFingerprintBuilder _fingerprintBuilder; + final ExternalSurfaceUrgencyKeyBuilder _urgencyKeyBuilder; + final ExternalSurfacePublish _publish; + final ExternalSurfaceClear? _clear; + final ExternalSurfaceCandidateGate? _candidateGate; + final void Function(Object error)? _onQueueError; + final bool _restartDebounce; + final bool _immediateBypassesMinimumInterval; + + Timer? _scheduledUpdate; + ExternalSurfaceSnapshotBuilder? _pendingSnapshotBuilder; + ExternalSurfacePreflightKeyBuilder? _pendingPreflightKeyBuilder; + String? _lastFingerprint; + Object? _lastPublishedUrgencyKey; + Object? _lastBuiltPreflightKey; + DateTime? _lastBuiltAt; + DateTime? _lastPublishedAt; + Future _operationChain = Future.value(); + bool _forceDelivery = false; + bool _bypassBuildFloor = false; + bool _didReconcileEmptyState = false; + bool _disabled = false; + bool _disposed = false; + + bool get hasPublishedFingerprint => _lastFingerprint != null; + + void schedule( + ExternalSurfaceSnapshotBuilder snapshotBuilder, { + required ExternalSurfacePreflightKeyBuilder preflightKeyBuilder, + bool immediate = false, + bool forceDelivery = false, + }) { + if (_disposed || _disabled || !_isEnabled()) return; + + _pendingSnapshotBuilder = snapshotBuilder; + _pendingPreflightKeyBuilder = preflightKeyBuilder; + if (forceDelivery) _forceDelivery = true; + if (_restartDebounce) { + _scheduledUpdate?.cancel(); + _scheduledUpdate = null; + } + + if (immediate) { + _scheduledUpdate?.cancel(); + _scheduledUpdate = null; + _enqueueFlush( + bypassMinimumInterval: _immediateBypassesMinimumInterval, + ); + return; + } + + _scheduledUpdate ??= Timer(_debounceDelay, _enqueueFlush); + } + + /// Re-attempt the latest pending projection after an owner-specific backoff. + void retryAfter(Duration delay, {bool bypassBuildFloor = true}) { + if (_disposed || _disabled || !_isEnabled()) return; + if (bypassBuildFloor) _bypassBuildFloor = true; + _scheduledUpdate?.cancel(); + _scheduledUpdate = Timer(delay, _enqueueFlush); + } + + /// Makes the next queued build independent of the ordinary pre-build floor. + void bypassNextBuildFloor() { + if (_disposed || _disabled) return; + _bypassBuildFloor = true; + } + + void _enqueueFlush({bool bypassMinimumInterval = false}) { + _operationChain = _operationChain + .then( + (_) => _flush(bypassMinimumInterval: bypassMinimumInterval), + ) + .catchError((Object error) { + _onQueueError?.call(error); + }); + } + + Future _flush({required bool bypassMinimumInterval}) async { + _scheduledUpdate?.cancel(); + _scheduledUpdate = null; + if (_disposed || _disabled || !_isEnabled()) return; + + final snapshotBuilder = _pendingSnapshotBuilder; + final preflightKeyBuilder = _pendingPreflightKeyBuilder; + if (snapshotBuilder == null || preflightKeyBuilder == null) return; + + final preflightKey = preflightKeyBuilder(); + final floorAnchor = _preflightPolicy == + ExternalSurfacePreflightPolicy.deduplicateAfterPublish + ? _lastPublishedAt + : _lastBuiltAt; + if (_preflightPolicy == + ExternalSurfacePreflightPolicy.deduplicateAfterPublish && + !bypassMinimumInterval && + _deferUntilFloor(floorAnchor)) { + return; + } + if (_preflightPolicy == + ExternalSurfacePreflightPolicy.deduplicateAfterPublish && + _lastFingerprint != null && + preflightKey == _lastBuiltPreflightKey) { + _clearPendingBuilders(); + return; + } + + final comparisonKey = _preflightPolicy == + ExternalSurfacePreflightPolicy.throttleAgainstPublishedUrgency + ? _lastPublishedUrgencyKey + : _lastBuiltPreflightKey; + final predictedUrgent = preflightKey != comparisonKey; + final buildFloorApplies = _preflightPolicy != + ExternalSurfacePreflightPolicy.deduplicateAfterPublish && + !_bypassBuildFloor && + !predictedUrgent; + if (buildFloorApplies && _deferUntilFloor(floorAnchor)) return; + + _bypassBuildFloor = false; + if (_preflightPolicy != + ExternalSurfacePreflightPolicy.deduplicateAfterPublish) { + _lastBuiltAt = DateTime.now(); + if (_preflightPolicy == + ExternalSurfacePreflightPolicy.throttleAgainstLastBuild) { + _lastBuiltPreflightKey = preflightKey; + } + } + + final snapshot = snapshotBuilder(); + if (_preflightPolicy == + ExternalSurfacePreflightPolicy.deduplicateAfterPublish) { + _clearPendingBuilders(); + } + if (snapshot == null) { + await _clearEmptyState(); + return; + } + + final payload = _payloadBuilder(snapshot); + final fingerprint = _fingerprintBuilder(payload); + final forceDelivery = _forceDelivery; + if (!forceDelivery && fingerprint == _lastFingerprint) { + if (_preflightPolicy == + ExternalSurfacePreflightPolicy.deduplicateAfterPublish) { + _lastBuiltPreflightKey = preflightKey; + } + return; + } + + final urgencyKey = _urgencyKeyBuilder(snapshot); + final urgent = urgencyKey != _lastPublishedUrgencyKey; + final publication = ExternalSurfacePublication( + snapshot: snapshot, + payload: payload, + fingerprint: fingerprint, + urgent: urgent, + forceDelivery: forceDelivery, + ); + if (_candidateGate != null && !_candidateGate(publication)) return; + + if (!urgent && + !bypassMinimumInterval && + _deferUntilFloor(_lastPublishedAt)) { + return; + } + + final result = await _publish(publication); + switch (result.disposition) { + case ExternalSurfacePublishDisposition.published: + _forceDelivery = false; + _lastFingerprint = fingerprint; + _lastPublishedUrgencyKey = urgencyKey; + _lastBuiltPreflightKey = preflightKey; + _lastPublishedAt = DateTime.now(); + _didReconcileEmptyState = false; + break; + case ExternalSurfacePublishDisposition.rejected: + final retryAfter = result.retryAfter; + if (retryAfter != null) this.retryAfter(retryAfter); + break; + case ExternalSurfacePublishDisposition.disable: + disable(); + break; + } + } + + bool _deferUntilFloor(DateTime? anchor) { + if (anchor == null) return false; + final elapsed = DateTime.now().difference(anchor); + if (elapsed >= _minimumNonUrgentInterval) return false; + _scheduledUpdate?.cancel(); + _scheduledUpdate = Timer( + _minimumNonUrgentInterval - elapsed, + _enqueueFlush, + ); + return true; + } + + Future _clearEmptyState() async { + final clear = _clear; + if (clear == null) return; + if (_lastFingerprint == null && _didReconcileEmptyState) return; + await clear(immediate: _lastFingerprint == null); + _didReconcileEmptyState = true; + resetPublishedState(reconciledEmptyState: true); + } + + void resetPublishedState({bool reconciledEmptyState = false}) { + _lastFingerprint = null; + _lastPublishedUrgencyKey = null; + _lastBuiltPreflightKey = null; + _lastBuiltAt = null; + _lastPublishedAt = null; + _didReconcileEmptyState = reconciledEmptyState; + } + + void disable() { + if (_disabled) return; + _disabled = true; + _scheduledUpdate?.cancel(); + _scheduledUpdate = null; + _clearPendingBuilders(); + _forceDelivery = false; + _bypassBuildFloor = false; + } + + void _clearPendingBuilders() { + _pendingSnapshotBuilder = null; + _pendingPreflightKeyBuilder = null; + } + + void dispose() { + if (_disposed) return; + _disposed = true; + _scheduledUpdate?.cancel(); + _scheduledUpdate = null; + _clearPendingBuilders(); + } +} diff --git a/lib/services/external_surfaces/geo/external_surface_geo_builder.dart b/lib/services/external_surfaces/geo/external_surface_geo_builder.dart new file mode 100644 index 00000000..4ebe98f1 --- /dev/null +++ b/lib/services/external_surfaces/geo/external_surface_geo_builder.dart @@ -0,0 +1,471 @@ +import '../../../models/log_entry.dart'; +import '../../../models/ping_data.dart'; +import '../../../models/repeater.dart'; +import '../../../providers/app_state_provider.dart' show OverlayPingType; +import '../../../utils/ping_colors.dart'; +import '../external_surface_color.dart'; +import 'external_surface_geo_models.dart'; + +/// Pure builders for geography shared by native external surfaces. +/// +/// Kept free of provider and platform dependencies so the caps, decimation, +/// and colour resolution can be unit-tested directly; those are exactly the +/// rules that would otherwise only fail on a wrist, in a car, at speed. +class ExternalSurfaceGeoBuilder { + ExternalSurfaceGeoBuilder._(); + + /// Colour for a ping marker, matching the iOS map's `_coverageStatusColor`. + static ExternalSurfaceColor pingColor(String kind, bool success) { + switch (kind) { + case 'tx': + return ExternalSurfaceColor.fromColor( + success ? PingColors.txSuccess : PingColors.txFail, + ); + case 'rx': + return ExternalSurfaceColor.fromColor(PingColors.rx); + case 'disc': + return ExternalSurfaceColor.fromColor( + success ? PingColors.discSuccess : PingColors.discFail, + ); + case 'trace': + return ExternalSurfaceColor.fromColor( + success ? PingColors.traceSuccess : PingColors.noResponse, + ); + default: + return ExternalSurfaceColor.fromColor(PingColors.noResponse); + } + } + + static ExternalSurfaceColor _txPingColor(TxPing ping) { + final success = ping.heardRepeaters.isNotEmpty; + final hasDirectEcho = + ping.heardRepeaters.any((repeater) => repeater.pathHops == null); + final hasMultiHopOnly = !hasDirectEcho && success; + // A multi-hop-only return is RX evidence, not proof that a repeater heard + // the transmitter directly. This is the phone map's marker rule. + return hasMultiHopOnly ? pingColor('rx', true) : pingColor('tx', success); + } + + /// Outcome colour for the newest coverage event of any kind. + /// + /// Scanning the four bounded histories avoids constructing and sorting the + /// map-marker list a second time merely to colour one dot. + static ExternalSurfaceColor? latestPingColor({ + required List txPings, + required List rxPings, + required List discLogEntries, + required List traceLogEntries, + }) { + DateTime? latestAt; + ExternalSurfaceColor? latestColor; + + void consider(DateTime at, ExternalSurfaceColor color) { + if (latestAt == null || at.isAfter(latestAt!)) { + latestAt = at; + latestColor = color; + } + } + + for (final ping in txPings) { + consider(ping.timestamp, _txPingColor(ping)); + } + for (final ping in rxPings) { + consider(ping.timestamp, pingColor('rx', true)); + } + for (final entry in discLogEntries) { + consider( + entry.timestamp, + pingColor('disc', entry.discoveredNodes.isNotEmpty), + ); + } + for (final entry in traceLogEntries) { + consider(entry.timestamp, pingColor('trace', entry.success)); + } + return latestColor; + } + + /// Colour for a repeater pin, matching the iOS map's `_repeaterStatusColor`. + static ExternalSurfaceColor repeaterColor(Repeater repeater) { + if (repeater.isDead) { + return ExternalSurfaceColor.fromColor(PingColors.repeaterDead); + } + if (repeater.isNew) { + return ExternalSurfaceColor.fromColor(PingColors.repeaterNew); + } + return ExternalSurfaceColor.fromColor(PingColors.repeaterActive); + } + + /// Most recent pings, newest first, capped at [ExternalSurfaceGeoWire.maxPings]. + /// + /// Every coverage source is merged before the recency cap is applied. + /// + /// Applying the cap to source-ordered batches could let a busy TX history + /// erase discovery or trace markers. Sorting the complete candidate set + /// first makes the wire carry the latest drive history regardless of type. + static List buildPings({ + required List txPings, + required List rxPings, + required List discLogEntries, + required List traceLogEntries, + int cap = ExternalSurfaceGeoWire.maxPings, + }) { + final pings = []; + + // Identity must not depend on a marker's position in its source list. + // These histories insert at the front and trim from the back, so a + // positional id renames every surviving marker whenever one arrives, and + // SwiftUI then tears down and rebuilds all sixty annotations to show one + // new dot. Timestamps are intrinsic to the event and survive both. + final seen = {}; + String stableId(String kind, DateTime at) { + final base = '$kind-${at.millisecondsSinceEpoch}'; + final n = seen.update(base, (v) => v + 1, ifAbsent: () => 0); + // Two events of one kind inside the same millisecond are the only case + // needing a discriminator, and it stays put because it counts within the + // colliding group rather than across the whole list. + return n == 0 ? base : '$base~$n'; + } + + for (final tx in txPings) { + pings.add(ExternalSurfacePing( + id: stableId('tx', tx.timestamp), + lat: tx.latitude, + lon: tx.longitude, + kind: 'tx', + color: _txPingColor(tx), + at: tx.timestamp, + )); + } + + for (final rx in rxPings) { + pings.add(ExternalSurfacePing( + id: stableId('rx', rx.timestamp), + lat: rx.latitude, + lon: rx.longitude, + kind: 'rx', + color: pingColor('rx', true), + at: rx.timestamp, + )); + } + + for (final entry in discLogEntries) { + pings.add(ExternalSurfacePing( + id: stableId('disc', entry.timestamp), + lat: entry.latitude, + lon: entry.longitude, + kind: 'disc', + color: pingColor('disc', entry.discoveredNodes.isNotEmpty), + at: entry.timestamp, + )); + } + + for (final entry in traceLogEntries) { + pings.add(ExternalSurfacePing( + id: stableId('trace', entry.timestamp), + lat: entry.latitude, + lon: entry.longitude, + kind: 'trace', + color: pingColor('trace', entry.success), + at: entry.timestamp, + )); + } + + pings.sort((a, b) => b.at.compareTo(a.at)); + if (pings.length <= cap) return pings; + return pings.sublist(0, cap); + } + + /// Repeaters nearest [lat]/[lon], capped at [ExternalSurfaceGeoWire.maxRepeaters]. + /// + /// Repeaters at the API's `(0, 0)` "location unknown" sentinel are excluded: + /// plotting them would drop a pin in the Gulf of Guinea. + static List buildRepeaters({ + required List repeaters, + required Set heardThisCycle, + double? lat, + double? lon, + int cap = ExternalSurfaceGeoWire.maxRepeaters, + }) { + final located = repeaters.where((r) => r.hasLocation).toList(); + final heardRepeaters = resolveUniqueHexPrefixes( + repeaters: repeaters, + prefixes: heardThisCycle, + ).values.map((repeater) => repeater.id).toSet(); + + if (lat != null && lon != null) { + // Distance is computed once per repeater rather than inside the + // comparator: this runs on every rebuild during an active session, and + // a zone can hold hundreds of repeaters. + final ranked = located + .map((r) => ( + repeater: r, + distance: ExternalSurfaceGeoWire.distanceMeters( + lat, lon, r.lat, r.lon), + )) + .toList() + ..sort((a, b) => a.distance.compareTo(b.distance)); + located + ..clear() + ..addAll(ranked.map((e) => e.repeater)); + } + + final limited = located.length > cap ? located.sublist(0, cap) : located; + + return limited + .map((r) => ExternalSurfaceRepeater( + id: r.id, + hexId: r.hexId, + name: r.name, + lat: r.lat, + lon: r.lon, + color: repeaterColor(r), + heardThisCycle: heardRepeaters.contains(r.id), + )) + .toList(); + } + + /// Resolve path-hash prefixes only when the full repeater catalogue proves + /// the match unique. Applying this before the wrist's nearest-20 cap matters: + /// a second matching repeater outside that cap still makes a line or ring a + /// guess, and a confidently wrong relationship is worse than none. + static Map resolveUniqueHexPrefixes({ + required List repeaters, + required Iterable prefixes, + }) { + final normalized = prefixes + .map((prefix) => prefix.toUpperCase()) + .where((prefix) => prefix.isNotEmpty) + .toSet(); + final resolved = {}; + + for (final length in normalized.map((prefix) => prefix.length).toSet()) { + final index = indexByHexPrefix(repeaters, length); + for (final prefix + in normalized.where((prefix) => prefix.length == length)) { + final repeater = index[prefix]; + if (repeater != null) resolved[prefix] = repeater; + } + } + return resolved; + } + + /// Resolve one identifier to a repeater, or null when it is not unambiguous. + /// + /// Tries exact identity first (the catalogue's short ID, its hex ID, or the + /// hex truncated to the zone's hop width, which is the form an overlay row is + /// labelled with) and only then a unique prefix relation. + /// + /// **A repeater with no `hexId` is never a prefix candidate.** The API omits + /// `hex_id` often enough that `Repeater.fromJson` defaults it to the empty + /// string, and `id.startsWith('')` is true for every id, so a single such + /// entry silently made every prefix lookup ambiguous and every name vanish. + static Repeater? resolveRepeater({ + required List repeaters, + required String id, + int? hopBytes, + }) { + final needle = id.toUpperCase(); + if (needle.isEmpty) return null; + + final exact = repeaters.where((repeater) { + return repeater.id.toUpperCase() == needle || + repeater.hexId.toUpperCase() == needle || + repeater.displayHexId(overrideHopBytes: hopBytes).toUpperCase() == + needle; + }).toList(growable: false); + if (exact.length == 1) return exact.single; + if (exact.isNotEmpty || needle.length < 2) return null; + + final prefixed = repeaters.where((repeater) { + final hexId = repeater.hexId.toUpperCase(); + if (hexId.isEmpty) return false; + return hexId.startsWith(needle) || needle.startsWith(hexId); + }).toList(growable: false); + return prefixed.length == 1 ? prefixed.single : null; + } + + /// Resolve overlay display hashes to repeaters, preferring the fuller + /// identity the ping actually carried. + /// + /// A path hash is 1–3 bytes, so in a busy zone it routinely matches several + /// repeaters and [resolveUniqueHexPrefixes] correctly refuses to name any of + /// them. But the phone often knows exactly who answered: a discovery response + /// carries the responder's full 64-character public key, and a trace carries + /// its 4-byte target. Resolving from the truncated hash threw that away, and + /// left the wrist reading "Name unresolved" for a node the phone could have + /// named precisely. + /// + /// [identities] maps a display hash to the longest identity known for the row + /// shown under it. It must describe the **current** rows only: a hash that + /// meant one repeater in a discovery response says nothing about who a later + /// TX echo under the same hash was, and carrying it over would turn a refusal + /// to guess into a confident wrong answer. + /// + /// Uniqueness is still required. A longer identity makes a collision + /// vanishingly unlikely rather than impossible, and a confidently wrong name + /// stays worse than none. + static Map resolveOverlayRepeaters({ + required List repeaters, + required Iterable displayIds, + Map identities = const {}, + }) { + final resolved = {}; + final unresolved = []; + + for (final rawId in displayIds) { + final displayId = rawId.toUpperCase(); + if (displayId.isEmpty) continue; + + final identity = identities[displayId]?.toUpperCase(); + if (identity == null || identity.isEmpty) { + unresolved.add(displayId); + continue; + } + + // Both values are the leading hex of the same public key, so whichever + // is shorter is a prefix of the other. + final matches = repeaters.where((repeater) { + final hexId = repeater.hexId.toUpperCase(); + if (hexId.isEmpty) return false; + return hexId.startsWith(identity) || identity.startsWith(hexId); + }).toList(growable: false); + + if (matches.length == 1) { + resolved[displayId] = matches.single; + } else { + // No match at all means the catalogue simply has no hex for this + // repeater; more than one means the fuller identity did not help. + // Either way the ordinary prefix rules get their turn. + unresolved.add(displayId); + } + } + + resolved.addAll(resolveUniqueHexPrefixes( + repeaters: repeaters, + prefixes: unresolved, + )); + return resolved; + } + + /// Dot colour for an overlay row, mirroring `_overlayTypeColor` on the map. + static ExternalSurfaceColor overlayTypeColor(OverlayPingType type) => + switch (type) { + OverlayPingType.tx => + ExternalSurfaceColor.fromColor(PingColors.txSuccess), + OverlayPingType.disc => + ExternalSurfaceColor.fromColor(PingColors.discSuccess), + OverlayPingType.trace => + ExternalSurfaceColor.fromColor(PingColors.traceSuccess), + OverlayPingType.rx => ExternalSurfaceColor.fromColor(PingColors.rx), + }; + + /// SNR quality colour shared by every native glance surface. + static ExternalSurfaceColor snrColor(double snr) => + ExternalSurfaceColor.fromColor(PingColors.snrColor(snr)); + + /// The "Top Heard" overlay rows: up to three top-SNR repeaters from the + /// latest ping, then the current RX slot. + /// + /// Order is deliberate rather than a global SNR sort: it matches the map + /// overlay, where the RX slot is a distinct trailing row rather than a + /// competitor for the top three. + static List buildHeard({ + required List<({String repeaterId, double snr, OverlayPingType type})> top, + ({String repeaterId, double snr})? rxSlot, + required Map repeaterByHex, + required DateTime? topAt, + required DateTime? rxAt, + double? lat, + double? lon, + }) { + final rows = []; + + // Missing time means the provider cannot truthfully say when this set was + // heard. Omitting such a row is safer than presenting a plausible lie in + // Node Detail; every production mutation records its timestamp atomically. + for (final entry in top.where((_) => topAt != null)) { + rows.add(_row( + id: entry.repeaterId, + snr: entry.snr, + type: entry.type, + repeaterByHex: repeaterByHex, + at: topAt!, + lat: lat, + lon: lon, + )); + } + + if (rxSlot != null && rxAt != null) { + rows.add(_row( + id: rxSlot.repeaterId, + snr: rxSlot.snr, + type: OverlayPingType.rx, + repeaterByHex: repeaterByHex, + at: rxAt, + lat: lat, + lon: lon, + )); + } + + return rows.length > ExternalSurfaceGeoWire.maxHeard + ? rows.sublist(0, ExternalSurfaceGeoWire.maxHeard) + : rows; + } + + static ExternalSurfaceHeardNode _row({ + required String id, + required double snr, + required OverlayPingType type, + required Map repeaterByHex, + required DateTime at, + double? lat, + double? lon, + }) { + final hex = id.toUpperCase(); + final repeater = repeaterByHex[hex]; + + double? distance; + if (lat != null && + lon != null && + repeater != null && + repeater.hasLocation) { + distance = ExternalSurfaceGeoWire.distanceMeters( + lat, lon, repeater.lat, repeater.lon); + } + + return ExternalSurfaceHeardNode( + id: hex, + // Null rather than a guess: a short path hash can match several + // repeaters, and a confidently wrong name is worse than none. + name: repeater?.name, + snr: snr, + at: at, + distanceM: distance, + snrColor: snrColor(snr), + typeColor: overlayTypeColor(type), + ); + } + + /// Index repeaters by the hex prefix an overlay row would carry. + /// + /// Only unambiguous prefixes are kept: if two repeaters share the leading + /// hex at that length, neither is resolvable, which is exactly the condition + /// the app flags as ambiguous rather than papering over. + static Map indexByHexPrefix( + List repeaters, + int length, + ) { + final counts = {}; + final index = {}; + + for (final repeater in repeaters) { + if (repeater.hexId.length < length) continue; + final prefix = repeater.hexId.substring(0, length).toUpperCase(); + counts[prefix] = (counts[prefix] ?? 0) + 1; + index[prefix] = repeater; + } + + index.removeWhere((prefix, _) => (counts[prefix] ?? 0) > 1); + return index; + } +} diff --git a/lib/services/external_surfaces/geo/external_surface_geo_models.dart b/lib/services/external_surfaces/geo/external_surface_geo_models.dart new file mode 100644 index 00000000..3204ec68 --- /dev/null +++ b/lib/services/external_surfaces/geo/external_surface_geo_models.dart @@ -0,0 +1,182 @@ +import 'dart:math' as math; + +import '../external_surface_color.dart'; + +class ExternalSurfaceGeoWire { + ExternalSurfaceGeoWire._(); + + static const int maxPings = 60; + static const int maxRepeaters = 20; + static const int maxHeard = 4; + static const double minMoveMeters = 15.0; + + static bool movedEnough({ + required double? lastLat, + required double? lastLon, + required double lat, + required double lon, + double thresholdMeters = minMoveMeters, + }) { + if (lastLat == null || lastLon == null) return true; + return distanceMeters(lastLat, lastLon, lat, lon) >= thresholdMeters; + } + + /// Great-circle distance in metres. + static double distanceMeters( + double lat1, + double lon1, + double lat2, + double lon2, + ) { + const earthRadius = 6371000.0; + final dLat = _toRadians(lat2 - lat1); + final dLon = _toRadians(lon2 - lon1); + final a = math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(_toRadians(lat1)) * + math.cos(_toRadians(lat2)) * + math.sin(dLon / 2) * + math.sin(dLon / 2); + return earthRadius * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); + } + + static double _toRadians(double degrees) => degrees * math.pi / 180.0; +} + +class ExternalSurfacePosition { + const ExternalSurfacePosition({ + required this.lat, + required this.lon, + required this.fixedAt, + this.headingDeg, + this.accuracyM, + }); + + final double lat; + final double lon; + final double? headingDeg; + final double? accuracyM; + final DateTime fixedAt; + + Map toMap() => { + 'lat': lat, + 'lon': lon, + 'headingDeg': headingDeg, + 'accuracyM': accuracyM, + 'fixedAtMs': fixedAt.millisecondsSinceEpoch.toDouble(), + }; +} + +class ExternalSurfacePing { + const ExternalSurfacePing({ + required this.id, + required this.lat, + required this.lon, + required this.kind, + required this.color, + required this.at, + }); + + final String id; + final double lat; + final double lon; + final String kind; + final ExternalSurfaceColor color; + final DateTime at; + + Map toMap() => { + 'id': id, + 'lat': lat, + 'lon': lon, + 'kind': kind, + 'color': color.toMap(), + 'atMs': at.millisecondsSinceEpoch.toDouble(), + }; +} + +class ExternalSurfaceRepeater { + const ExternalSurfaceRepeater({ + required this.id, + required this.hexId, + required this.name, + required this.lat, + required this.lon, + required this.color, + required this.heardThisCycle, + }); + + final String id; + final String hexId; + final String name; + final double lat; + final double lon; + final ExternalSurfaceColor color; + final bool heardThisCycle; + + Map toMap() => { + 'id': id, + 'hexId': hexId, + 'name': name, + 'lat': lat, + 'lon': lon, + 'color': color.toMap(), + 'heardThisCycle': heardThisCycle, + }; +} + +class ExternalSurfaceHeardNode { + const ExternalSurfaceHeardNode({ + required this.id, + required this.typeColor, + required this.at, + this.name, + this.snr, + this.distanceM, + this.snrColor, + }); + + final String id; + final String? name; + final double? snr; + final DateTime at; + final double? distanceM; + final ExternalSurfaceColor? snrColor; + final ExternalSurfaceColor typeColor; + + Map toMap() => { + 'id': id, + 'name': name, + 'snr': snr, + 'atMs': at.millisecondsSinceEpoch.toDouble(), + 'distanceM': distanceM, + 'snrColor': snrColor?.toMap(), + 'typeColor': typeColor.toMap(), + }; +} + +class ExternalSurfaceGeo { + const ExternalSurfaceGeo({ + required this.pings, + required this.repeaters, + required this.heard, + required this.linkedRepeaterIds, + this.you, + }); + + final ExternalSurfacePosition? you; + final List pings; + final List repeaters; + final List heard; + final List linkedRepeaterIds; + + Map toMap({bool includeMapDetail = true}) => { + 'you': you?.toMap(), + 'pings': includeMapDetail + ? pings.map((ping) => ping.toMap()).toList() + : const [], + 'repeaters': includeMapDetail + ? repeaters.map((repeater) => repeater.toMap()).toList() + : const [], + 'heard': heard.map((node) => node.toMap()).toList(), + 'linkedRepeaterIds': includeMapDetail ? linkedRepeaterIds : const [], + }; +} diff --git a/lib/services/gps_service.dart b/lib/services/gps_service.dart index 1535a629..a90873d1 100644 --- a/lib/services/gps_service.dart +++ b/lib/services/gps_service.dart @@ -7,6 +7,7 @@ import 'package:permission_handler/permission_handler.dart'; import '../models/connection_state.dart'; import '../utils/debug_logger_io.dart'; +import '../utils/geo_validation.dart'; import 'gps_simulator_service.dart'; /// GPS service for location tracking @@ -15,25 +16,46 @@ import 'gps_simulator_service.dart'; class GpsService { /// Minimum distance (meters) from last ping before allowing new ping static const double minDistanceMeters = 25.0; - + /// Maximum GPS age for manual pings (60 seconds) /// Reference: GPS_WATCH_MAX_AGE_MS in wardrive.js static const Duration maxGpsAgeForManualPing = Duration(seconds: 60); - + /// Maximum GPS accuracy threshold for pings (100 meters) /// Reference: GPS_ACCURACY_THRESHOLD_M in wardrive.js docs static const double maxAccuracyMetersForPing = 100.0; - + /// Maximum GPS accuracy threshold for zone checks (50 meters) /// Reference: getValidGpsForZoneCheck() in wardrive.js static const double maxAccuracyMetersForZoneCheck = 50.0; + /// Configured minimum ping distance (user-adjustable, clamped to minDistanceMeters floor) + double _configuredMinDistance = minDistanceMeters; + + /// Get the configured minimum ping distance + double get configuredMinDistance => _configuredMinDistance; + + /// Set the minimum ping distance (clamped to 25m floor) + void setMinPingDistance(double meters) { + _configuredMinDistance = + meters < minDistanceMeters ? minDistanceMeters : meters; + debugLog( + '[GPS] Min ping distance set to ${_configuredMinDistance.toInt()}m'); + } + final _statusController = StreamController.broadcast(); final _positionController = StreamController.broadcast(); GpsStatus _status = GpsStatus.permissionDenied; Position? _lastPosition; Position? _lastPingPosition; + + /// Last ping of ANY type (TX, discovery, trace) for the map's + /// distance-to-previous-ping readout (#501). Kept separate from + /// _lastPingPosition, the TX skip anchor: feeding discovery/trace positions + /// into that would change canPingAtPosition() and let a Hybrid discovery + /// skip the following TX ping at walking pace. + Position? _lastActivityPosition; StreamSubscription? _positionSubscription; /// GPS Simulator for testing @@ -93,7 +115,8 @@ class GpsService { } if (permission == LocationPermission.deniedForever) { - debugLog('[GPS] Permission denied forever - user must enable in Settings'); + debugLog( + '[GPS] Permission denied forever - user must enable in Settings'); _updateStatus(GpsStatus.permissionDenied); return false; } @@ -131,7 +154,8 @@ class GpsService { // If denied forever, can't request again - user must go to settings if (current == LocationPermission.deniedForever) { - debugLog('[GPS] Permission denied forever - user must enable in Settings'); + debugLog( + '[GPS] Permission denied forever - user must enable in Settings'); return false; } @@ -161,11 +185,21 @@ class GpsService { Future startWatching() async { debugLog('[GPS] startWatching() called, current status: $_status'); + // Ensure only one active position stream subscription exists. + // startWatching() can be called multiple times (e.g. after permission flow). + if (_positionSubscription != null) { + debugLog( + '[GPS] Existing position subscription found, restarting watcher'); + await _positionSubscription?.cancel(); + _positionSubscription = null; + } + // Check if location services are enabled first (system-level setting) final serviceEnabled = await isLocationServiceEnabled(); debugLog('[GPS] Location services check: enabled=$serviceEnabled'); if (!serviceEnabled) { - debugLog('[GPS] Location services DISABLED at system level - user must enable in Settings'); + debugLog( + '[GPS] Location services DISABLED at system level - user must enable in Settings'); _updateStatus(GpsStatus.disabled); return; } @@ -179,21 +213,31 @@ class GpsService { final permission = await Geolocator.checkPermission(); final hasPermission = permission == LocationPermission.always || permission == LocationPermission.whileInUse; - debugLog('[GPS] Permission check: $permission (hasPermission=$hasPermission)'); + debugLog( + '[GPS] Permission check: $permission (hasPermission=$hasPermission)'); if (!hasPermission) { if (permission == LocationPermission.deniedForever) { - debugLog('[GPS] Permission denied forever - user must enable in Settings'); + debugLog( + '[GPS] Permission denied forever - user must enable in Settings'); } else { - debugLog('[GPS] Permission not granted - waiting for disclosure flow'); + debugLog( + '[GPS] Permission not granted - waiting for disclosure flow'); } _updateStatus(GpsStatus.permissionDenied); return; } } else { - debugLog('[GPS] Web platform - skipping permission pre-check, will prompt via position stream'); + debugLog( + '[GPS] Web platform - skipping permission pre-check, will prompt via position stream'); } debugLog('[GPS] Starting position stream listener...'); + + // Cancel any existing subscription to prevent orphaned listeners + // (e.g. restartGpsAfterPermission() racing with _initialize()) + await _positionSubscription?.cancel(); + _positionSubscription = null; + _updateStatus(GpsStatus.searching); // Configure location settings for position stream @@ -202,12 +246,22 @@ class GpsService { _positionSubscription = Geolocator.getPositionStream( locationSettings: const LocationSettings( accuracy: LocationAccuracy.high, - distanceFilter: 10, // Trigger every 10m movement (check RX batches at 25m) + distanceFilter: + 10, // Trigger every 10m movement (check RX batches at 25m) ), ).listen( (position) { - debugLog('[GPS SERVICE] Position stream fired: lat=${position.latitude.toStringAsFixed(5)}, ' + debugLog( + '[GPS SERVICE] Position stream fired: lat=${position.latitude.toStringAsFixed(5)}, ' 'lon=${position.longitude.toStringAsFixed(5)}, accuracy=${position.accuracy.toStringAsFixed(1)}m'); + // Drop invalid fixes (NaN/infinite/out-of-range). iOS can briefly + // report an invalid CLLocation after resume; passing it downstream + // aborts the app in MapLibre and poisons the upload payload. + if (!isValidLatLng(position.latitude, position.longitude)) { + debugWarn( + '[GPS] Dropping invalid position: lat=${position.latitude}, lon=${position.longitude}'); + return; + } _lastPosition = position; _positionController.add(position); @@ -227,7 +281,8 @@ class GpsService { desiredAccuracy: LocationAccuracy.high, timeLimit: const Duration(seconds: 15), ); - debugLog('[GPS] Initial position acquired: ${position.latitude.toStringAsFixed(5)}, ' + debugLog( + '[GPS] Initial position acquired: ${position.latitude.toStringAsFixed(5)}, ' '${position.longitude.toStringAsFixed(5)} (accuracy: ${position.accuracy.toStringAsFixed(1)}m)'); _lastPosition = position; // Note: Don't emit via _positionController here — the stream listener @@ -235,7 +290,8 @@ class GpsService { // would cause duplicate position events (~0.15ms apart). _updateStatus(GpsStatus.locked); } catch (e) { - debugLog('[GPS] Initial position request failed: $e (will wait for stream updates)'); + debugLog( + '[GPS] Initial position request failed: $e (will wait for stream updates)'); // Will receive updates from stream } } @@ -260,15 +316,35 @@ class GpsService { ); } - /// Check if current position is far enough from last ping (25m minimum) + /// Check if current position is far enough from last ping bool canPingAtPosition(Position position) { if (_lastPingPosition == null) return true; - return distanceFromLastPing(position) >= minDistanceMeters; + return distanceFromLastPing(position) >= _configuredMinDistance; } - /// Mark current position as ping location + /// Mark current position as TX ping location (skip anchor + readout) void markPingPosition(Position position) { _lastPingPosition = position; + _lastActivityPosition = position; + } + + /// Mark a non-TX ping (discovery, trace) for the distance readout only. + /// Does NOT move the TX skip anchor used by canPingAtPosition(). + void markActivityPosition(Position position) { + _lastActivityPosition = position; + } + + /// Calculate distance from the last ping of any type, for display + double distanceFromLastActivity(Position current) { + if (_lastActivityPosition == null) { + return double.infinity; + } + return Geolocator.distanceBetween( + _lastActivityPosition!.latitude, + _lastActivityPosition!.longitude, + current.latitude, + current.longitude, + ); } /// Check if GPS position is fresh enough for manual pings (< 60s old) @@ -277,19 +353,19 @@ class GpsService { final age = DateTime.now().difference(position.timestamp); return age <= maxGpsAgeForManualPing; } - + /// Check if GPS position has acceptable accuracy for pings (< 100m) /// Reference: GPS_ACCURACY_THRESHOLD_M in wardrive.js bool isAccuracyAcceptableForPing(Position position) { return position.accuracy <= maxAccuracyMetersForPing; } - + /// Check if GPS position has acceptable accuracy for zone checks (< 50m) /// Reference: getValidGpsForZoneCheck() in wardrive.js bool isAccuracyAcceptableForZoneCheck(Position position) { return position.accuracy <= maxAccuracyMetersForZoneCheck; } - + /// Validate position for ping operation /// Checks freshness (< 60s old) and accuracy (< 100m) /// Returns null if valid, error message if invalid @@ -300,17 +376,17 @@ class GpsService { debugWarn('[GPS] Position too old: ${age}s (max 60s)'); return 'GPS data too old ($age seconds)'; } - + // Check accuracy if (!isAccuracyAcceptableForPing(position)) { final accuracy = position.accuracy.toInt(); debugWarn('[GPS] Position too inaccurate: ${accuracy}m (max 100m)'); return 'GPS accuracy too low ($accuracy meters)'; } - + return null; // Valid } - + /// Validate position for zone check operation /// Checks freshness (< 60s old) and accuracy (< 50m, stricter than ping) /// Returns null if valid, error message if invalid @@ -321,17 +397,48 @@ class GpsService { debugWarn('[GPS] [AUTH] Position too old: ${age}s (max 60s)'); return 'GPS data too old ($age seconds)'; } - + // Check accuracy (stricter for zone checks) if (!isAccuracyAcceptableForZoneCheck(position)) { final accuracy = position.accuracy.toInt(); debugWarn('[GPS] [AUTH] Position too inaccurate: ${accuracy}m (max 50m)'); return 'GPS accuracy too low ($accuracy meters)'; } - + return null; // Valid } + /// Request a fresh GPS position from the hardware for auto-ping accuracy. + /// On mobile, this forces a warm-start GPS read (typically < 1 second when + /// GPS is already streaming). Falls back to lastPosition on timeout/error. + Future getFreshPosition( + {Duration timeout = const Duration(seconds: 3)}) async { + // Simulator provides its own positions — use cached + if (_simulatorEnabled) { + return _lastPosition; + } + + try { + final position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + timeLimit: timeout, + ); + debugLog( + '[GPS] Fresh position acquired: ${position.latitude.toStringAsFixed(5)}, ' + '${position.longitude.toStringAsFixed(5)} (accuracy: ${position.accuracy.toStringAsFixed(1)}m)'); + if (!isValidLatLng(position.latitude, position.longitude)) { + debugWarn( + '[GPS] Fresh position has invalid coords, using cached: lat=${position.latitude}, lon=${position.longitude}'); + return _lastPosition; + } + _lastPosition = position; + return position; + } catch (e) { + debugLog('[GPS] Fresh position request failed, using cached: $e'); + return _lastPosition; + } + } + /// Get current position (single request) Future getCurrentPosition() async { if (!await requestPermissions()) { @@ -345,10 +452,16 @@ class GpsService { } try { - return await Geolocator.getCurrentPosition( + final position = await Geolocator.getCurrentPosition( desiredAccuracy: LocationAccuracy.high, timeLimit: const Duration(seconds: 15), ); + if (!isValidLatLng(position.latitude, position.longitude)) { + debugWarn( + '[GPS] getCurrentPosition returned invalid coords: lat=${position.latitude}, lon=${position.longitude}'); + return null; + } + return position; } catch (e) { debugLog('[GPS] getCurrentPosition failed: $e'); return null; @@ -390,6 +503,11 @@ class GpsService { // Subscribe to simulator positions _simulatorSubscription = simulator.positionStream.listen((position) { + if (!isValidLatLng(position.latitude, position.longitude)) { + debugWarn( + '[GPS] Dropping invalid simulator position: lat=${position.latitude}, lon=${position.longitude}'); + return; + } _lastPosition = position; _positionController.add(position); @@ -401,9 +519,10 @@ class GpsService { _simulatorEnabled = true; // Set initial position immediately from simulator - if (simulator.currentPosition != null) { - _lastPosition = simulator.currentPosition; - _positionController.add(simulator.currentPosition!); + final seed = simulator.currentPosition; + if (seed != null && isValidLatLng(seed.latitude, seed.longitude)) { + _lastPosition = seed; + _positionController.add(seed); } _updateStatus(GpsStatus.locked); // Simulator always has "lock" diff --git a/lib/services/gps_simulator_service.dart b/lib/services/gps_simulator_service.dart index 92185b81..0c1b4499 100644 --- a/lib/services/gps_simulator_service.dart +++ b/lib/services/gps_simulator_service.dart @@ -10,10 +10,13 @@ import '../utils/debug_logger_io.dart'; enum SimulatorPattern { /// Move in a straight line in the configured direction straight, + /// Move in a circle around the start point circle, + /// Random walk with smooth direction changes randomWalk, + /// Follow a loaded route (KML/GPX) route, } @@ -143,7 +146,8 @@ class GpsSimulatorService { _circleAngle = 0; } - debugLog('[GPS SIM] Configured: speed=${_speed}km/h, pattern=$_pattern, heading=$_heading'); + debugLog( + '[GPS SIM] Configured: speed=${_speed}km/h, pattern=$_pattern, heading=$_heading'); } /// Start the simulator @@ -151,7 +155,8 @@ class GpsSimulatorService { if (_isRunning) return; _isRunning = true; - debugLog('[GPS SIM] Starting simulator: ${_latitude.toStringAsFixed(5)}, ${_longitude.toStringAsFixed(5)} @ ${_speed}km/h'); + debugLog( + '[GPS SIM] Starting simulator: ${_latitude.toStringAsFixed(5)}, ${_longitude.toStringAsFixed(5)} @ ${_speed}km/h'); // Emit initial position immediately _emitPosition(); @@ -184,7 +189,8 @@ class GpsSimulatorService { _targetHeading = 45; _routeIndex = 0; _routeProgress = 0; - debugLog('[GPS SIM] Reset to: ${_latitude.toStringAsFixed(5)}, ${_longitude.toStringAsFixed(5)}'); + debugLog( + '[GPS SIM] Reset to: ${_latitude.toStringAsFixed(5)}, ${_longitude.toStringAsFixed(5)}'); } /// Load route from KML file content @@ -236,7 +242,8 @@ class GpsSimulatorService { _latitude = _routePoints[0].latitude; _longitude = _routePoints[0].longitude; - debugLog('[GPS SIM] Loaded KML route "$_routeName" with ${_routePoints.length} points'); + debugLog( + '[GPS SIM] Loaded KML route "$_routeName" with ${_routePoints.length} points'); return true; } catch (e) { debugLog('[GPS SIM] Error parsing KML: $e'); @@ -263,10 +270,12 @@ class GpsSimulatorService { final lat = double.tryParse(pt.getAttribute('lat') ?? ''); final lon = double.tryParse(pt.getAttribute('lon') ?? ''); final eleElement = pt.findElements('ele').firstOrNull; - final alt = eleElement != null ? double.tryParse(eleElement.innerText) : null; + final alt = + eleElement != null ? double.tryParse(eleElement.innerText) : null; if (lat != null && lon != null) { - coordinates.add(RoutePoint(latitude: lat, longitude: lon, altitude: alt)); + coordinates + .add(RoutePoint(latitude: lat, longitude: lon, altitude: alt)); } } @@ -309,7 +318,8 @@ class GpsSimulatorService { _latitude = _routePoints[0].latitude; _longitude = _routePoints[0].longitude; - debugLog('[GPS SIM] Loaded GPX route "$_routeName" with ${_routePoints.length} points'); + debugLog( + '[GPS SIM] Loaded GPX route "$_routeName" with ${_routePoints.length} points'); return true; } catch (e) { debugLog('[GPS SIM] Error parsing GPX: $e'); @@ -320,18 +330,30 @@ class GpsSimulatorService { /// Extract route name from GPX document String _extractGpxName(XmlDocument document) { // Try track name first - final trkName = document.findAllElements('trk').firstOrNull - ?.findElements('name').firstOrNull?.innerText; + final trkName = document + .findAllElements('trk') + .firstOrNull + ?.findElements('name') + .firstOrNull + ?.innerText; if (trkName != null) return trkName; // Try route name - final rteName = document.findAllElements('rte').firstOrNull - ?.findElements('name').firstOrNull?.innerText; + final rteName = document + .findAllElements('rte') + .firstOrNull + ?.findElements('name') + .firstOrNull + ?.innerText; if (rteName != null) return rteName; // Try metadata name - final metaName = document.findAllElements('metadata').firstOrNull - ?.findElements('name').firstOrNull?.innerText; + final metaName = document + .findAllElements('metadata') + .firstOrNull + ?.findElements('name') + .firstOrNull + ?.innerText; if (metaName != null) return metaName; return 'Unnamed Route'; @@ -412,8 +434,10 @@ class GpsSimulatorService { // Calculate distance between current and next point final segmentDistanceM = _haversineDistance( - currentPoint.latitude, currentPoint.longitude, - nextPoint.latitude, nextPoint.longitude, + currentPoint.latitude, + currentPoint.longitude, + nextPoint.latitude, + nextPoint.longitude, ); if (segmentDistanceM < 1) { @@ -461,24 +485,31 @@ class GpsSimulatorService { final nextPoint = _routePoints[nextIndex]; final t = _routeProgress.clamp(0.0, 1.0); - _latitude = currentPoint.latitude + (nextPoint.latitude - currentPoint.latitude) * t; - _longitude = currentPoint.longitude + (nextPoint.longitude - currentPoint.longitude) * t; + _latitude = currentPoint.latitude + + (nextPoint.latitude - currentPoint.latitude) * t; + _longitude = currentPoint.longitude + + (nextPoint.longitude - currentPoint.longitude) * t; // Calculate heading towards next point _heading = _calculateBearing( - currentPoint.latitude, currentPoint.longitude, - nextPoint.latitude, nextPoint.longitude, + currentPoint.latitude, + currentPoint.longitude, + nextPoint.latitude, + nextPoint.longitude, ); } /// Haversine distance between two points in meters - double _haversineDistance(double lat1, double lon1, double lat2, double lon2) { + double _haversineDistance( + double lat1, double lon1, double lat2, double lon2) { const R = 6371000.0; // Earth 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); + 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; } @@ -490,8 +521,8 @@ class GpsSimulatorService { final lat2Rad = lat2 * pi / 180; final y = sin(dLon) * cos(lat2Rad); - final x = cos(lat1Rad) * sin(lat2Rad) - - sin(lat1Rad) * cos(lat2Rad) * cos(dLon); + final x = + cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon); final bearing = atan2(y, x) * 180 / pi; return (bearing + 360) % 360; // Normalize to 0-360 } @@ -509,7 +540,8 @@ class GpsSimulatorService { // 1 degree latitude ≈ 111 km // 1 degree longitude ≈ 111 km * cos(latitude) final latChange = (distanceKm / 111) * cos(headingRad); - final lonChange = (distanceKm / (111 * cos(_latitude * pi / 180))) * sin(headingRad); + final lonChange = + (distanceKm / (111 * cos(_latitude * pi / 180))) * sin(headingRad); _latitude += latChange; _longitude += lonChange; @@ -530,7 +562,8 @@ class GpsSimulatorService { // Calculate position on circle final angleRad = _circleAngle * pi / 180; _latitude = _circleCenterLat + _circleRadius * cos(angleRad); - _longitude = _circleCenterLon + _circleRadius * sin(angleRad) / cos(_circleCenterLat * pi / 180); + _longitude = _circleCenterLon + + _circleRadius * sin(angleRad) / cos(_circleCenterLat * pi / 180); // Update heading to be tangent to circle _heading = (_circleAngle + 90) % 360; diff --git a/lib/services/link_decision.dart b/lib/services/link_decision.dart new file mode 100644 index 00000000..d7ecb97f --- /dev/null +++ b/lib/services/link_decision.dart @@ -0,0 +1,50 @@ +/// What to do about offering an account link for the freshly connected radio. +enum LinkFlowDecision { skip, prompt } + +/// Pure decision for the post-connection account-link offer. +/// +/// Kept free of clocks, I/O and provider state so the whole truth table is +/// testable. The time-dependent parts (retry backoff, per-pubkey attempt cap) +/// stay in `AppStateProvider`, which checks them immediately before calling +/// this. +/// +/// Linking is strictly non-fatal: EVERY uncertainty resolves to +/// [LinkFlowDecision.skip]. +/// +/// * [loggedIn] — a portal token is held. +/// * [offlineMode] / [anonymousMode] — user has opted out of identifying the +/// session; do not attach it to an account. +/// * [autoPingActive] — a wardrive is running (or is about to be restored after +/// a reconnect); never interrupt a drive with a modal. +/// * [autoReconnecting] — a BLE flap mid-drive must not re-prompt. +/// * [pubkey] — the radio's public key; null/empty means nothing to link. +/// * [declined] — the user said no for this pubkey and it was persisted. +/// * [linked] — the cache already has this pubkey bound to the account. +/// * [signUnsupported] — the firmware answered ERR to CMD_SIGN_START, or gave +/// repeated bad signatures. Re-prompting burns rate-limited nonces. +/// * [promptedThisAppSession] — asked once already since app launch. This is +/// per app session, NOT per connection, so a reconnect cannot re-ask. +LinkFlowDecision decideLinkFlow({ + required bool loggedIn, + required bool offlineMode, + required bool anonymousMode, + required bool autoPingActive, + required bool autoReconnecting, + required String? pubkey, + required bool declined, + required bool linked, + required bool signUnsupported, + required bool promptedThisAppSession, +}) { + if (!loggedIn) return LinkFlowDecision.skip; + if (offlineMode) return LinkFlowDecision.skip; + if (anonymousMode) return LinkFlowDecision.skip; + if (autoPingActive) return LinkFlowDecision.skip; + if (autoReconnecting) return LinkFlowDecision.skip; + if (pubkey == null || pubkey.isEmpty) return LinkFlowDecision.skip; + if (declined) return LinkFlowDecision.skip; + if (linked) return LinkFlowDecision.skip; + if (signUnsupported) return LinkFlowDecision.skip; + if (promptedThisAppSession) return LinkFlowDecision.skip; + return LinkFlowDecision.prompt; +} diff --git a/lib/services/live_activity/live_activity_models.dart b/lib/services/live_activity/live_activity_models.dart new file mode 100644 index 00000000..152f91d6 --- /dev/null +++ b/lib/services/live_activity/live_activity_models.dart @@ -0,0 +1,248 @@ +import '../watch/watch_color.dart'; + +/// High-level phase shared by native glance surfaces. +/// +/// [idle] is watch-only: the Live Activity builder still uses its session-only +/// resolver directly, while the always-present watch projects that resolver's +/// no-session fallback to this value. +enum LiveActivityPhase { + idle, + active, + starting, + sending, + discovering, + tracing, + listening, + listeningDiscovery, + listeningTrace, + waiting, + waitingDiscovery, + waitingTrace, + cooldown, + skipped, + stopping, + waitingForGps, + pausedOutsideZone, + disconnected, + txBlocked, +} + +extension LiveActivityPhaseWireValue on LiveActivityPhase { + String get wireValue => switch (this) { + LiveActivityPhase.idle => 'idle', + LiveActivityPhase.active => 'active', + LiveActivityPhase.starting => 'starting', + LiveActivityPhase.sending => 'sending', + LiveActivityPhase.discovering => 'discovering', + LiveActivityPhase.tracing => 'tracing', + LiveActivityPhase.listening => 'listening', + LiveActivityPhase.listeningDiscovery => 'listening_discovery', + LiveActivityPhase.listeningTrace => 'listening_trace', + LiveActivityPhase.waiting => 'waiting', + LiveActivityPhase.waitingDiscovery => 'waiting_discovery', + LiveActivityPhase.waitingTrace => 'waiting_trace', + LiveActivityPhase.cooldown => 'cooldown', + LiveActivityPhase.skipped => 'skipped', + LiveActivityPhase.stopping => 'stopping', + LiveActivityPhase.waitingForGps => 'waiting_for_gps', + LiveActivityPhase.pausedOutsideZone => 'paused_outside_zone', + LiveActivityPhase.disconnected => 'disconnected', + LiveActivityPhase.txBlocked => 'tx_blocked', + }; +} + +/// Compact repeater observation included in a Live Activity update. +class LiveActivityRepeater { + const LiveActivityRepeater({ + required this.id, + required this.snr, + this.name, + this.typeColor, + this.snrColor, + }); + + final String id; + final String? name; + final double snr; + final WatchColor? typeColor; + final WatchColor? snrColor; + + Map toMap() => { + 'id': id, + 'name': name, + 'snr': snr.isFinite ? snr : 0.0, + if (typeColor != null) 'typeColor': typeColor!.toMap(), + if (snrColor != null) 'snrColor': snrColor!.toMap(), + }; +} + +/// Complete, serializable snapshot rendered by ActivityKit. +class LiveActivitySnapshot { + const LiveActivitySnapshot({ + required this.sessionId, + required this.mode, + required this.phase, + required this.phaseTitle, + required this.isConnected, + required this.txCount, + required this.rxCount, + required this.discoveryCount, + required this.traceCount, + required this.queueSize, + required this.repeaters, + required this.totalHeardCount, + required this.repeatersAreCurrent, + required this.updatedAt, + this.phaseDetail, + this.phaseEndsAt, + this.phaseDurationMs, + this.pingColor, + this.modeColor, + this.zoneCode, + this.noiseFloorDbm, + this.companionBatteryPct, + this.showRepeaterNames = true, + }); + + final String sessionId; + final String mode; + final LiveActivityPhase phase; + final String phaseTitle; + final String? phaseDetail; + final DateTime? phaseEndsAt; + final int? phaseDurationMs; + final WatchColor? pingColor; + + /// The running mode's identity color from the active ping palette, tinting + /// the lock screen's progress bar the way the app colors that ping type. + final WatchColor? modeColor; + + final bool isConnected; + final String? zoneCode; + + /// Latest radio noise floor in whole dBm, for the Live Activity header. + /// Null when the radio has not answered a stats poll yet. + final int? noiseFloorDbm; + + /// Companion radio battery, already bucketed to 5 percent steps so routine + /// drain does not change the payload fingerprint on every poll. + final int? companionBatteryPct; + + /// Whether the small card spells out resolved repeater names (fewer, named + /// rows) or packs the two-column hex grid. Mirrors the Settings switch. + final bool showRepeaterNames; + final int txCount; + final int rxCount; + final int discoveryCount; + final int traceCount; + final int queueSize; + final List repeaters; + final int totalHeardCount; + final bool repeatersAreCurrent; + final DateTime updatedAt; + + Map toMap() => { + 'sessionId': sessionId, + 'mode': mode, + 'phase': phase.wireValue, + 'phaseTitle': phaseTitle, + 'phaseDetail': phaseDetail, + 'phaseEndsAt': phaseEndsAt?.millisecondsSinceEpoch, + if (phaseDurationMs != null) 'phaseDurationMs': phaseDurationMs, + if (pingColor != null) 'pingColor': pingColor!.toMap(), + if (modeColor != null) 'modeColor': modeColor!.toMap(), + 'isConnected': isConnected, + 'zoneCode': zoneCode, + if (noiseFloorDbm != null) 'noiseFloorDbm': noiseFloorDbm, + if (companionBatteryPct != null) + 'companionBatteryPct': companionBatteryPct, + 'showRepeaterNames': showRepeaterNames, + 'txCount': txCount, + 'rxCount': rxCount, + 'discoveryCount': discoveryCount, + 'traceCount': traceCount, + 'queueSize': queueSize, + 'repeaters': repeaters.map((repeater) => repeater.toMap()).toList(), + 'totalHeardCount': totalHeardCount, + 'repeatersAreCurrent': repeatersAreCurrent, + 'updatedAt': updatedAt.millisecondsSinceEpoch, + }; + + /// Fields that must bypass the normal update throttle. + String get urgencyKey => [ + buildPreflightUrgencyKey( + sessionId: sessionId, + mode: mode, + phase: phase, + phaseTitle: phaseTitle, + phaseDetail: phaseDetail, + phaseEndsAt: phaseEndsAt, + phaseDurationMs: phaseDurationMs, + isConnected: isConnected, + zoneCode: zoneCode, + showRepeaterNames: showRepeaterNames, + ), + pingColor?.r ?? '', + pingColor?.g ?? '', + pingColor?.b ?? '', + ].join('|'); + + /// Every urgent field that can be resolved without walking a ping history. + /// + /// [LiveActivityService] needs to decide whether a flush may wait *before* it + /// builds a snapshot, and building one resolves the latest ping colour by + /// scanning the full TX, RX, discovery and trace logs. Sharing this formatter + /// with [urgencyKey] keeps the cheap preflight and the eventual decision from + /// drifting on what counts as news. + /// + /// The ping colour is the one urgent field left out, because it is the + /// expensive one. In practice a ping outcome moves the phase with it, so this + /// key changes anyway; a colour that somehow moved alone is held for at most + /// one throttle interval rather than being dropped. + static String buildPreflightUrgencyKey({ + required String sessionId, + required String mode, + required LiveActivityPhase phase, + required String phaseTitle, + required String? phaseDetail, + required DateTime? phaseEndsAt, + required int? phaseDurationMs, + required bool isConnected, + required String? zoneCode, + // Urgent because it is a user gesture: the wearer just flipped a display + // switch and is looking at the card to see it change. + bool showRepeaterNames = true, + }) => + [ + sessionId, + mode, + phase.wireValue, + phaseTitle, + phaseDetail ?? '', + // Seconds, not milliseconds. **At millisecond resolution any + // recomputation of the deadline reads as news** and forces an immediate + // send past the 15 s non-urgent throttle, because the key differs even + // when the phase has not changed. + // + // Measured on the 2026-08-16 walk, from the phone's powerlog + // (`PLApplicationAgent_EventPoint_LiveActivityUpdates`): 720 updates in + // 90 minutes, one every 7.5 s, against Apple Fitness's 2 in the same + // window. 69 % of the gaps were under the throttle, and 116 of them + // were **under one second** — which no real phase change can produce, + // and which the 200 ms debounce should already have absorbed. + // + // A deadline that moves by milliseconds is not something a wearer can + // see: the widget renders it with `Text(timerInterval:)` and + // `ProgressView(timerInterval:)`, both of which show whole seconds. So + // rounding here cannot lose anything the surface could display, while a + // genuine phase change moves the deadline by seconds at minimum and + // still bypasses the throttle exactly as intended. + phaseEndsAt == null + ? 0 + : (phaseEndsAt.millisecondsSinceEpoch / 1000).round(), + phaseDurationMs ?? 0, + isConnected, + zoneCode ?? '', + showRepeaterNames, + ].join('|'); +} diff --git a/lib/services/live_activity/live_activity_service.dart b/lib/services/live_activity/live_activity_service.dart new file mode 100644 index 00000000..da9e8d8f --- /dev/null +++ b/lib/services/live_activity/live_activity_service.dart @@ -0,0 +1,208 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../../utils/debug_logger_io.dart'; +import '../external_surfaces/external_surface_publisher.dart'; +import 'live_activity_models.dart'; + +typedef LiveActivitySnapshotBuilder = LiveActivitySnapshot? Function(); + +/// The urgent half of the next snapshot, resolved without building one. +/// +/// Returns null when no activity should exist, which is how ending stays +/// immediate rather than waiting out the non-urgent floor. +typedef LiveActivityUrgencyKeyBuilder = String? Function(); + +/// Owns the Flutter-to-ActivityKit bridge and coalesces noisy app-state changes. +/// +/// Timer ticks are represented by absolute phase deadlines, allowing SwiftUI to +/// render the countdown locally without an ActivityKit update every second. +class LiveActivityService { + LiveActivityService({ + @visibleForTesting MethodChannel? channel, + @visibleForTesting + Duration unavailableRetryDelay = const Duration(seconds: 30), + @visibleForTesting + Duration minimumNonUrgentInterval = defaultMinimumNonUrgentInterval, + }) : _channel = channel ?? const MethodChannel('meshmapper/live_activity'), + _unavailableRetryDelay = unavailableRetryDelay { + _publisher = + ExternalSurfacePublisher>( + debounceDelay: _debounceDelay, + minimumNonUrgentInterval: minimumNonUrgentInterval, + isEnabled: () => isSupportedPlatform && !_hostCannotHostActivities, + preflightPolicy: ExternalSurfacePreflightPolicy.throttleAgainstLastBuild, + payloadBuilder: (snapshot) => snapshot.toMap(), + fingerprintBuilder: (payload) { + final fingerprint = Map.from(payload) + ..remove('updatedAt'); + return jsonEncode(fingerprint); + }, + urgencyKeyBuilder: (snapshot) => snapshot.urgencyKey, + publish: _publishSnapshot, + clear: _endCurrentActivity, + onQueueError: (error) { + debugError('[LIVE ACTIVITY] Update queue failed: $error'); + }, + ); + } + + static const Duration _debounceDelay = Duration(milliseconds: 200); + + /// Floor between two updates that carry no change the wearer is waiting on. + /// + /// Counters, queue depth and the heard-repeater rows churn constantly in a + /// busy session, and none of them is worth an ActivityKit round trip the + /// moment it moves. Everything in [LiveActivitySnapshot.urgencyKey] — a phase + /// change, a ping outcome, connection loss, leaving the zone — bypasses this + /// entirely, so what it throttles is the noise and not the news. + /// + /// **This is a saving on both devices.** A locally generated ActivityKit + /// update is synchronised to a paired Apple Watch for the Smart Stack and + /// counts against *its* Live Activity budget, so the phone's update rate is + /// also a wrist battery cost — on top of the snapshots the native watch app + /// receives over WatchConnectivity, which are a separate channel entirely. + /// + /// Fifteen seconds takes the ceiling from 30 updates a minute to 4. The real + /// reduction is smaller, because the payload fingerprint already suppresses + /// updates that change nothing. + static const Duration defaultMinimumNonUrgentInterval = Duration(seconds: 15); + + /// Native's answer when the host can never show a Live Activity — iOS below + /// 16.2. Distinct from `false`, which means "not right now" and is what the + /// authorization backoff exists for. + static const String unsupportedHost = 'unsupported'; + + final MethodChannel _channel; + final Duration _unavailableRetryDelay; + late final ExternalSurfacePublisher> _publisher; + String? _unavailableSessionId; + DateTime? _unavailableRetryAt; + bool _disposed = false; + + /// Set once native reports a condition that cannot change while this process + /// lives: an OS without ActivityKit, or a host with no such channel at all. + /// + /// Before this, both answered every 30 s retry with the same guaranteed + /// failure, and kept doing it for the whole session — a timer, a build and a + /// channel round trip apiece, on devices that were never going to show + /// anything. + bool _hostCannotHostActivities = false; + + bool get isSupportedPlatform => + !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; + + void schedule( + LiveActivitySnapshotBuilder snapshotBuilder, { + required LiveActivityUrgencyKeyBuilder urgencyKeyBuilder, + bool immediate = false, + }) { + if (_disposed || !isSupportedPlatform || _hostCannotHostActivities) return; + _publisher.schedule( + snapshotBuilder, + preflightKeyBuilder: urgencyKeyBuilder, + immediate: immediate, + ); + } + + Future _publishSnapshot( + ExternalSurfacePublication> + publication, + ) async { + final snapshot = publication.snapshot; + if (_unavailableSessionId == snapshot.sessionId) { + final retryAt = _unavailableRetryAt; + final now = DateTime.now(); + if (retryAt != null && now.isBefore(retryAt)) { + // Authorization can be enabled in Settings while this session is + // running. One scheduled retry makes that recover without asking + // ActivityKit on every high-frequency provider notification. + return ExternalSurfacePublishResult.rejected( + retryAfter: retryAt.difference(now), + ); + } + _unavailableSessionId = null; + _unavailableRetryAt = null; + } else if (_unavailableSessionId != null) { + _unavailableSessionId = null; + _unavailableRetryAt = null; + } + + try { + final result = + await _channel.invokeMethod('sync', publication.payload); + if (result == unsupportedHost) { + _stopForUnsupportedHost('iOS 16.2 is required for Live Activities'); + return const ExternalSurfacePublishResult.disable(); + } + if (result == false) { + _unavailableSessionId = snapshot.sessionId; + _unavailableRetryAt = DateTime.now().add(_unavailableRetryDelay); + debugLog('[LIVE ACTIVITY] Live Activities are unavailable or disabled'); + return ExternalSurfacePublishResult.rejected( + retryAfter: _unavailableRetryDelay, + ); + } + _unavailableSessionId = null; + _unavailableRetryAt = null; + return const ExternalSurfacePublishResult.published(); + } on MissingPluginException { + // A host with no such channel — a non-iOS test host, or an iOS project + // generated before this feature existed. Neither gains one at runtime, + // so this is the same permanent condition as an OS below 16.2. + _stopForUnsupportedHost('no Live Activity channel on this host'); + return const ExternalSurfacePublishResult.disable(); + } on PlatformException catch (error) { + debugError( + '[LIVE ACTIVITY] ActivityKit sync failed: ' + '${error.code}: ${error.message}', + ); + return const ExternalSurfacePublishResult.rejected(); + } catch (error) { + debugError('[LIVE ACTIVITY] Unexpected sync failure: $error'); + return const ExternalSurfacePublishResult.rejected(); + } + } + + /// Stop asking, permanently. Nothing about the condition can change without + /// the app being relaunched, at which point this starts out false again. + void _stopForUnsupportedHost(String reason) { + if (_hostCannotHostActivities) return; + _hostCannotHostActivities = true; + _publisher.disable(); + _unavailableSessionId = null; + _unavailableRetryAt = null; + debugLog('[LIVE ACTIVITY] Disabled for this session: $reason'); + } + + Future _endCurrentActivity({required bool immediate}) async { + try { + await _channel.invokeMethod( + 'end', + {'immediate': immediate}, + ); + } on MissingPluginException { + // Expected on non-iOS test hosts. + } on PlatformException catch (error) { + debugError( + '[LIVE ACTIVITY] ActivityKit end failed: ' + '${error.code}: ${error.message}', + ); + } catch (error) { + debugError('[LIVE ACTIVITY] Unexpected end failure: $error'); + } finally { + _unavailableSessionId = null; + _unavailableRetryAt = null; + } + } + + void dispose() { + _disposed = true; + _publisher.dispose(); + _unavailableRetryAt = null; + } +} diff --git a/lib/services/meshcore/buffer_utils.dart b/lib/services/meshcore/buffer_utils.dart index 57345363..d7f80362 100644 --- a/lib/services/meshcore/buffer_utils.dart +++ b/lib/services/meshcore/buffer_utils.dart @@ -106,7 +106,6 @@ class BufferReader { } return value; } - } /// Buffer writer for creating binary data for MeshCore devices @@ -155,16 +154,16 @@ class BufferWriter { void writeCString(String string, int maxLength) { final encoded = utf8.encode(string); final bytes = Uint8List(maxLength); - + // Copy string bytes up to maxLength - 1 final copyLength = math.min(encoded.length, maxLength - 1); for (int i = 0; i < copyLength; i++) { bytes[i] = encoded[i]; } - + // Ensure last byte is null terminator bytes[maxLength - 1] = 0; - + writeBytes(bytes); } diff --git a/lib/services/meshcore/channel_service.dart b/lib/services/meshcore/channel_service.dart index 44873978..56f04c0a 100644 --- a/lib/services/meshcore/channel_service.dart +++ b/lib/services/meshcore/channel_service.dart @@ -38,13 +38,17 @@ class ChannelService { // Always add #wardriving (required for TX) final wardrivingKey = CryptoService.getChannelKey(wardrivingChannelName); final wardrivingHash = CryptoService.computeChannelHash(wardrivingKey); - _allowedChannels[wardrivingChannelName] = _ChannelData(key: wardrivingKey, hash: wardrivingHash); + _allowedChannels[wardrivingChannelName] = + _ChannelData(key: wardrivingKey, hash: wardrivingHash); debugLog('[CHANNEL] Added: $wardrivingChannelName -> hash=$wardrivingHash'); // Add regional channels from API for (final name in channelNames) { - final channelName = name.toLowerCase() == 'public' ? 'Public' : - name.startsWith('#') ? name : '#$name'; + final channelName = name.toLowerCase() == 'public' + ? 'Public' + : name.startsWith('#') + ? name + : '#$name'; // Skip if already added if (_allowedChannels.containsKey(channelName)) continue; @@ -95,7 +99,8 @@ class ChannelService { /// Get all allowed channels for RX validation /// Returns a map of channel hash -> channel info for use with PacketValidator - static Map getAllowedChannelsForValidator() { + static Map + getAllowedChannelsForValidator() { final result = {}; for (final entry in _allowedChannels.entries) { result[entry.value.hash] = ( @@ -114,7 +119,8 @@ class ChannelService { /// @param connection - Active MeshCore connection /// @returns ChannelInfo for the created channel /// @throws Exception if no empty slots or creation fails - static Future createWardrivingChannel(MeshCoreConnection connection) async { + static Future createWardrivingChannel( + MeshCoreConnection connection) async { debugLog('[CHANNEL] Attempting to create channel: $wardrivingChannelName'); // Get all channels @@ -143,9 +149,11 @@ class ChannelService { final channelKey = CryptoService.deriveChannelKey(wardrivingChannelName); // Create the channel - debugLog('[CHANNEL] Creating channel $wardrivingChannelName at index $emptyIdx'); + debugLog( + '[CHANNEL] Creating channel $wardrivingChannelName at index $emptyIdx'); await connection.setChannel(emptyIdx, wardrivingChannelName, channelKey); - debugLog('[CHANNEL] Channel $wardrivingChannelName created successfully at index $emptyIdx'); + debugLog( + '[CHANNEL] Channel $wardrivingChannelName created successfully at index $emptyIdx'); // Return channel info return ChannelInfo( @@ -161,14 +169,14 @@ class ChannelService { /// /// @param connection - Active MeshCore connection /// @returns ChannelInfo for the wardriving channel - static Future ensureWardrivingChannel(MeshCoreConnection connection) async { + static Future ensureWardrivingChannel( + MeshCoreConnection connection) async { debugLog('[CHANNEL] Looking up channel: $wardrivingChannelName'); // Scan ALL channels to find #wardriving or first empty slot // Full scan prevents duplicates from orphaned channels after unexpected disconnects int? firstEmptySlot; var channelIdx = 0; - while (true) { try { // Retry mechanism for first channel (sometimes gets spurious OK responses) @@ -179,7 +187,8 @@ class ChannelService { try { channel = await connection.getChannel(channelIdx); } catch (e) { - debugLog('[CHANNEL] First getChannel failed (likely spurious OK), retrying: $e'); + debugLog( + '[CHANNEL] First getChannel failed (likely spurious OK), retrying: $e'); await Future.delayed(const Duration(milliseconds: 100)); channel = await connection.getChannel(channelIdx); } @@ -189,7 +198,8 @@ class ChannelService { // Found existing #wardriving channel - return immediately! if (channel.name == wardrivingChannelName) { - debugLog('[CHANNEL] Found existing channel at index ${channel.channelIndex} (scanned ${channelIdx + 1} channels)'); + debugLog( + '[CHANNEL] Found existing channel at index ${channel.channelIndex} (scanned ${channelIdx + 1} channels)'); return channel; } @@ -211,16 +221,27 @@ class ChannelService { // #wardriving not found - create it at first empty slot if (firstEmptySlot == null) { - debugError('[CHANNEL] No empty channel slots found in first $channelIdx channels'); + if (channelIdx == 0) { + // Couldn't read any channels — BLE dropped, not a channel issue + debugError('[CHANNEL] BLE connection lost during channel scan'); + throw Exception( + 'BLE connection lost during channel setup. Please try connecting again.', + ); + } + debugError( + '[CHANNEL] No empty channel slots found in first $channelIdx channels'); throw Exception( 'No empty channel slots available. Please free a channel slot on your companion first.', ); } - debugLog('[CHANNEL] #wardriving not found in $channelIdx channels, creating at index $firstEmptySlot'); + debugLog( + '[CHANNEL] #wardriving not found in $channelIdx channels, creating at index $firstEmptySlot'); final channelKey = CryptoService.deriveChannelKey(wardrivingChannelName); - await connection.setChannel(firstEmptySlot, wardrivingChannelName, channelKey); - debugLog('[CHANNEL] Channel $wardrivingChannelName created successfully at index $firstEmptySlot'); + await connection.setChannel( + firstEmptySlot, wardrivingChannelName, channelKey); + debugLog( + '[CHANNEL] Channel $wardrivingChannelName created successfully at index $firstEmptySlot'); return ChannelInfo( channelIndex: firstEmptySlot, @@ -230,7 +251,7 @@ class ChannelService { } /// Delete #wardriving channel on disconnect - /// + /// /// @param connection - Active MeshCore connection /// @param channelIdx - Index of the channel to delete static Future deleteWardrivingChannel( diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 5594b2f9..3168363f 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -5,7 +5,7 @@ import 'dart:typed_data'; import '../../models/connection_state.dart'; import '../../models/device_model.dart'; import '../../utils/debug_logger_io.dart'; -import '../bluetooth/bluetooth_service.dart'; +import '../transport/companion_transport.dart'; import 'buffer_utils.dart'; import 'channel_service.dart'; import 'crypto_service.dart'; @@ -17,12 +17,16 @@ class DeviceQueryResponse { final int protocolVersion; final String manufacturer; final String? firmwareBuildDate; // Added in protocol v8 - final int? pathHashMode; // 0=1-byte, 1=2-byte, 2=3-byte (null if old firmware, v10+) + final String? + firmwareVersionString; // e.g. "v1.14.0-9f1a3ea" (v7+, 20-byte C-string) + final int? + pathHashMode; // 0=1-byte, 1=2-byte, 2=3-byte (null if old firmware, v10+) const DeviceQueryResponse({ required this.protocolVersion, required this.manufacturer, this.firmwareBuildDate, + this.firmwareVersionString, this.pathHashMode, }); } @@ -36,16 +40,82 @@ class SelfInfo { final Uint8List publicKey; final String name; + /// Radio configuration reported in the SelfInfo response (newer firmware only; + /// null on older firmware that omits the radio block). Encoding as sent by the device: + /// frequency in kHz, bandwidth in Hz, SF/CR raw. (The companion-protocol wiki documents + /// freq as Hz, but real hardware reports kHz — a 910.525 MHz radio sends 910525.) + final int? radioFreqKHz; + final int? radioBwHz; + final int? radioSf; + final int? radioCr; + const SelfInfo({ required this.type, required this.txPower, required this.maxTxPower, required this.publicKey, required this.name, + this.radioFreqKHz, + this.radioBwHz, + this.radioSf, + this.radioCr, }); /// Get public key as hex string - String get publicKeyHex => publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + String get publicKeyHex => publicKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join('') + .toUpperCase(); + + /// Whether the device reported a usable radio configuration. + bool get hasRadioConfig => radioFreqKHz != null && radioFreqKHz! > 0; + + /// Compact radio config for the API: "freqMHz,bwKHz,SF,CR" (e.g. "910.525,62.5,7,5"). + /// Frequency kHz→MHz (÷1000), bandwidth Hz→kHz (÷1000). Null when no radio params. + String? get radioConfigApi { + if (!hasRadioConfig) return null; + final freq = _trimNum(radioFreqKHz! / 1e3); + final bw = _trimNum((radioBwHz ?? 0) / 1e3); + return '$freq,$bw,${radioSf ?? 0},${radioCr ?? 0}'; + } + + /// Human-readable radio config for the UI: "910.525 MHz · 62.5 kHz · SF7 · CR5". + /// Null when unavailable. + String? get radioConfigDisplay { + if (!hasRadioConfig) return null; + final freq = _trimNum(radioFreqKHz! / 1e3); + final bw = _trimNum((radioBwHz ?? 0) / 1e3); + return '$freq MHz · $bw kHz · SF${radioSf ?? 0} · CR${radioCr ?? 0}'; + } + + /// Format a number with up to 3 decimals, trimming trailing zeros and a trailing dot + /// (910.525 → "910.525", 62.5 → "62.5", 915.0 → "915"). + static String _trimNum(double v) { + var s = v.toStringAsFixed(3); + if (s.contains('.')) { + s = s.replaceAll(RegExp(r'0+$'), '').replaceAll(RegExp(r'\.$'), ''); + } + return s; + } +} + +/// Thrown by [MeshCoreConnection.sign] for protocol-level sign failures. +/// +/// [code] is stable and is what callers branch on: +/// * `unsupported` — the radio answered CMD_SIGN_START with ERR (old firmware) +/// * `data_too_long` — payload exceeds the radio's `maxSignDataLen` +/// * `malformed_response` — a truncated RESP_SIGN_START / RESP_SIGNATURE frame +/// * `bad_signature_length` — the radio returned something other than 64 bytes +/// * `err` — the radio answered ERR mid-sign +/// * `aborted` — the connection closed while a sign was in flight +class SignException implements Exception { + final String code; + final String message; + + const SignException(this.code, this.message); + + @override + String toString() => 'SignException($code): $message'; } /// MeshCore connection manager @@ -62,12 +132,17 @@ class SelfInfo { /// 8. GPS Init /// 9. Connected State class MeshCoreConnection { - final BluetoothService _bluetooth; + final CompanionTransport _transport; + bool _disposed = false; final _stepController = StreamController.broadcast(); - final _channelMessageController = StreamController.broadcast(); + final _channelMessageController = + StreamController.broadcast(); final _rawDataController = StreamController>.broadcast(); - final _logRxDataController = StreamController<({Uint8List raw, double snr, int rssi})>.broadcast(); - final _controlDataController = StreamController<({Uint8List raw, double snr, int rssi})>.broadcast(); + final _logRxDataController = + StreamController<({Uint8List raw, double snr, int rssi})>.broadcast(); + final _controlDataController = + StreamController<({Uint8List raw, double snr, int rssi})>.broadcast(); + final _traceDataController = StreamController.broadcast(); final _noiseFloorController = StreamController.broadcast(); final _batteryController = StreamController.broadcast(); @@ -81,9 +156,20 @@ class MeshCoreConnection { Completer? _deviceQueryCompleter; Completer? _selfInfoCompleter; Completer? _sentCompleter; + Completer? _setTimeCompleter; Completer? _channelInfoCompleter; Completer? _statsCompleter; Completer? _exportContactCompleter; + Completer? _getTimeCompleter; + + // CMD_SIGN state. `_signGate` is non-null for the whole duration of a sign; + // Task-4's write funnel queues every non-sign frame behind it so no other + // command's OK can be mistaken for a per-chunk sign ack. + bool _signInProgress = false; + Completer? _signStartCompleter; // resolves with maxSignDataLen + Completer? _signChunkOkCompleter; // resolves on each chunk's OK + Completer? _signatureCompleter; // resolves with the 64-byte sig + Completer? _signGate; // Device self info (contains public key) SelfInfo? _selfInfo; @@ -103,24 +189,32 @@ class MeshCoreConnection { int? _lastBatteryMilliVolts; // millivolts or null if not supported Timer? _batteryTimer; - MeshCoreConnection({required BluetoothService bluetooth}) : _bluetooth = bluetooth { - _dataSubscription = _bluetooth.dataStream.listen(_onFrameReceived); + MeshCoreConnection({required CompanionTransport transport}) + : _transport = transport { + _dataSubscription = _transport.dataStream.listen(_onFrameReceived); } /// Stream of connection step changes Stream get stepStream => _stepController.stream; /// Stream of channel messages (for RX pings) - Stream get channelMessageStream => _channelMessageController.stream; + Stream get channelMessageStream => + _channelMessageController.stream; /// Stream of raw data pushes Stream> get rawDataStream => _rawDataController.stream; /// Stream of LogRxData packets (for unified RX handler) - Stream<({Uint8List raw, double snr, int rssi})> get logRxDataStream => _logRxDataController.stream; + Stream<({Uint8List raw, double snr, int rssi})> get logRxDataStream => + _logRxDataController.stream; /// Stream of ControlData packets (for discovery responses) - Stream<({Uint8List raw, double snr, int rssi})> get controlDataStream => _controlDataController.stream; + Stream<({Uint8List raw, double snr, int rssi})> get controlDataStream => + _controlDataController.stream; + + /// Stream of TraceData packets (for trace path responses) + /// 0x89 has NO snr/rssi prefix — raw bytes are the trace payload directly + Stream get traceDataStream => _traceDataController.stream; /// Stream of noise floor updates (dBm) Stream get noiseFloorStream => _noiseFloorController.stream; @@ -164,13 +258,16 @@ class MeshCoreConnection { /// Wardriving channel hash (for echo correlation) - null if not connected int? get wardrivingChannelHash { final channel = _wardrivingChannel; - return channel != null ? CryptoService.computeChannelHash(channel.secret) : null; + return channel != null + ? CryptoService.computeChannelHash(channel.secret) + : null; } void _updateStep(ConnectionStep step) { _currentStep = step; - if (_stepController.isClosed) { - debugError('[CONN] Cannot update step - controller is closed!'); + if (_disposed || _stepController.isClosed) { + debugLog( + '[CONN] Ignoring step update on disposed connection (expected during reconnect)'); return; } debugLog('[CONN] Step: $step'); @@ -180,13 +277,16 @@ class MeshCoreConnection { /// Execute the full connection workflow /// Returns (deviceModel, deviceModelMatched) for display/reporting purposes /// Note: This method does NOT modify radio TX power settings - it only reads device info - Future<({DeviceModel? deviceModel, bool deviceModelMatched})> connect(String deviceId, List deviceModels) async { + Future<({DeviceModel? deviceModel, bool deviceModelMatched})> connect( + List deviceModels) async { + if (_disposed) { + throw Exception('Connection instance has been disposed'); + } bool deviceModelMatched = false; - + try { - // Step 1: BLE Connect - _updateStep(ConnectionStep.bleConnecting); - await _bluetooth.connect(deviceId); + // Step 1: Transport connect (already connected by caller) + _updateStep(ConnectionStep.transportConnecting); // Step 2: Protocol Handshake (handled automatically by device) _updateStep(ConnectionStep.protocolHandshake); @@ -194,7 +294,8 @@ class MeshCoreConnection { // Step 3: Device Query _updateStep(ConnectionStep.deviceQuery); - _deviceInfo = await deviceQuery(ProtocolConstants.supportedCompanionProtocolVersion); + _deviceInfo = await deviceQuery( + ProtocolConstants.supportedCompanionProtocolVersion); // Step 3b: Get Self Info (contains public key) // This is critical for geo-auth API authentication @@ -204,7 +305,8 @@ class MeshCoreConnection { if (pubKeyHex == null) { throw Exception('getSelfInfo() returned null public key'); } - debugLog('[CONN] Public key acquired: ${pubKeyHex.substring(0, 16)}...'); + debugLog( + '[CONN] Public key acquired: ${pubKeyHex.substring(0, 16)}...'); } catch (e) { debugError('[CONN] Failed to get self info (public key): $e'); // Public key is REQUIRED for geo-auth API @@ -220,9 +322,11 @@ class MeshCoreConnection { final matchedModel = _deviceModel; if (matchedModel != null) { deviceModelMatched = true; - debugLog('[CONN] Device identified: ${matchedModel.shortName} (reports ${matchedModel.power}W / ${matchedModel.txPower}dBm)'); + debugLog( + '[CONN] Device identified: ${matchedModel.shortName} (reports ${matchedModel.power}W / ${matchedModel.txPower}dBm)'); } else { - debugLog('[CONN] Device model not recognized - user must manually select power level for reporting'); + debugLog( + '[CONN] Device model not recognized - user must manually select power level for reporting'); } // Step 5: Time Sync @@ -237,20 +341,31 @@ class MeshCoreConnection { if (authResult == null || authResult['success'] != true) { final reason = authResult?['reason'] ?? 'unknown'; final message = authResult?['message'] ?? 'Authentication failed'; - debugError('[CONN] API session acquisition failed: $reason - $message'); + debugError( + '[CONN] API session acquisition failed: $reason - $message'); // Throw with reason code prefix for proper error handling throw Exception('AUTH_FAILED:$reason:$message'); } - debugLog('[CONN] API session acquired successfully (session_id: ${authResult['session_id']})'); + debugLog( + '[CONN] API session acquired successfully (session_id: ${authResult['session_id']})'); } else { - debugLog('[CONN] No auth callback set, skipping API session acquisition'); + debugLog( + '[CONN] No auth callback set, skipping API session acquisition'); + } + + // Guard: transport may have disconnected during the async auth API call + if (_disposed || + _transport.connectionStatus != ConnectionStatus.connected) { + throw Exception( + 'Transport disconnected during authentication. Please try connecting again.'); } // Step 7: Channel Setup _updateStep(ConnectionStep.channelSetup); debugLog('[CONN] Creating #wardriving channel'); _wardrivingChannel = await ChannelService.ensureWardrivingChannel(this); - debugLog('[CONN] Channel ready: ${_wardrivingChannel?.name ?? 'unknown'} (CH:${_wardrivingChannel?.channelIndex ?? -1})'); + debugLog( + '[CONN] Channel ready: ${_wardrivingChannel?.name ?? 'unknown'} (CH:${_wardrivingChannel?.channelIndex ?? -1})'); // Step 8: GPS Init (handled externally) _updateStep(ConnectionStep.gpsInit); @@ -270,13 +385,16 @@ class MeshCoreConnection { // This may fail on older firmware (< v1.11.0) _startNoiseFloorPolling(); - return (deviceModel: _deviceModel, deviceModelMatched: deviceModelMatched); + return ( + deviceModel: _deviceModel, + deviceModelMatched: deviceModelMatched + ); } catch (e) { debugError('[CONN] Connection failed: $e'); _updateStep(ConnectionStep.error); // Clean up BLE connection on failure try { - await _bluetooth.disconnect(); + await _transport.disconnect(); debugLog('[CONN] Disconnected BLE after connection failure'); } catch (disconnectError) { debugError('[CONN] Failed to disconnect after error: $disconnectError'); @@ -289,6 +407,9 @@ class MeshCoreConnection { /// Delete wardriving channel early (before stopping services) /// This should be called FIRST in the disconnect flow to ensure BLE is still connected Future deleteWardrivingChannelEarly() async { + // Channel deletion is a gated write; a live sign would park it behind the + // sign timeout while BLE is still up. Abort the sign first. + _abortPendingSign(); final channel = _wardrivingChannel; if (channel != null) { await ChannelService.deleteWardrivingChannel(this, channel.channelIndex); @@ -299,6 +420,7 @@ class MeshCoreConnection { Future disconnect() async { try { debugLog('[CONN] Disconnecting'); + _abortPendingSign(); // Stop noise floor polling _stopNoiseFloorPolling(); @@ -310,7 +432,7 @@ class MeshCoreConnection { // See deleteWardrivingChannelEarly() called from app_state_provider // Disconnect BLE - await _bluetooth.disconnect(); + await _transport.disconnect(); _deviceInfo = null; _deviceModel = null; _selfInfo = null; @@ -326,24 +448,25 @@ class MeshCoreConnection { /// Match manufacturer string to device model /// Reference: parseDeviceModel() in wardrive.js - DeviceModel? _matchDeviceModel(String manufacturer, List models) { + DeviceModel? _matchDeviceModel( + String manufacturer, List models) { // Strip build suffix (e.g., "nightly-e31c46f") final cleanManufacturer = manufacturer.split(' ').first; - + for (final model in models) { if (manufacturer.contains(model.manufacturer) || cleanManufacturer.contains(model.manufacturer)) { return model; } } - + // Try partial match on short name for (final model in models) { if (manufacturer.toLowerCase().contains(model.shortName.toLowerCase())) { return model; } } - + return null; } @@ -351,21 +474,85 @@ class MeshCoreConnection { void _onFrameReceived(Uint8List frame) { if (frame.isEmpty) return; + // A RESP_SIGNATURE payload is 64 bytes of Ed25519 signature over the + // portal's login nonce, and debug logs are uploadable to the bug-report + // endpoint — so this one frame is logged by length only, never as hex. + // Every other frame keeps the full hexdump. + final frameDump = frame[0] == ResponseCodes.signature + ? 'SIGNATURE payload redacted' + : _hexDump(frame); + try { - debugLog('[CONN] Frame received (${frame.length} bytes): ${_hexDump(frame)}'); - + debugLog('[CONN] Frame received (${frame.length} bytes): $frameDump'); + final reader = BufferReader(frame); final responseCode = reader.readByte(); - - debugLog('[CONN] Response code: 0x${responseCode.toRadixString(16).padLeft(2, '0')} ($responseCode)'); + + debugLog( + '[CONN] Response code: 0x${responseCode.toRadixString(16).padLeft(2, '0')} ($responseCode)'); switch (responseCode) { case ResponseCodes.ok: - debugLog('[CONN] Received OK response'); - break; + { + debugLog('[CONN] Received OK response'); + // A pending sign owns the bare OK. The write gate keeps every + // OK-emitting command *issued during* the sign off the wire, but it + // cannot recall one that was already awaiting its OK when the sign + // began. That is safe only because the sole other bare-OK consumer + // is setDeviceTime, which runs once during connect(). + final signOk = _signChunkOkCompleter; + if (signOk != null) { + _signChunkOkCompleter = null; + if (!signOk.isCompleted) signOk.complete(); + break; + } + _setTimeCompleter?.complete(); + _setTimeCompleter = null; + break; + } case ResponseCodes.err: - final errorCode = reader.remainingBytesCount > 0 ? reader.readByte() : 0; + final errorCode = + reader.remainingBytesCount > 0 ? reader.readByte() : 0; debugLog('[CONN] Received ERR response (error code: $errorCode)'); + // A pending sign claims this ERR. Without it the old-firmware feature + // detect hangs for the full sign timeout. + // + // The claim is not airtight, and deliberately so. The write gate + // keeps every command *issued during* the sign off the wire, but it + // cannot recall one that was already awaiting its response when the + // sign began — and unlike the bare OK (whose only other consumer is + // setDeviceTime, once per connect), ERR has five: stats, channel + // info, device query, export contact and get time, two of them + // timer-driven (getStats every 5s, battery every 30s). So a foreign + // ERR that lands in that window is misattributed to the sign. + // + // Bounded, not dangerous: the foreign command still times out on its + // own (getStats carries its own 5s timeout), and the sign fails and + // is retried. The one verdict worth protecting is "this firmware has + // no CMD_SIGN" — the persistence layer requires two strikes before + // recording it, so a single misattributed ERR cannot condemn a radio + // that actually supports signing. + if (_failPendingSign( + SignException('err', + 'Radio returned ERR during sign (error code $errorCode)'), + startError: SignException('unsupported', + 'Radio rejected CMD_SIGN_START (error code $errorCode)'), + )) { + break; + } + // Time sync: error code 6 (ERR_CODE_ILLEGAL_ARG) means "no sync needed" — treat as success + if (_setTimeCompleter != null) { + if (errorCode == 6) { + debugLog( + '[CONN] Time sync not needed (error code 6) - treating as success'); + } else { + debugWarn( + '[CONN] Time sync error (code $errorCode) - continuing anyway'); + } + _setTimeCompleter?.complete(); + _setTimeCompleter = null; + break; + } // Complete any pending completers with error final errException = Exception('Command error (code $errorCode)'); _statsCompleter?.completeError(errException); @@ -376,6 +563,8 @@ class MeshCoreConnection { _deviceQueryCompleter = null; _exportContactCompleter?.completeError(errException); _exportContactCompleter = null; + _getTimeCompleter?.completeError(errException); + _getTimeCompleter = null; break; case ResponseCodes.deviceInfo: _onDeviceInfoResponse(reader); @@ -401,23 +590,72 @@ class MeshCoreConnection { case PushCodes.controlData: _onControlDataPush(reader); break; + case PushCodes.traceData: + _onTraceDataPush(reader); + break; case ResponseCodes.stats: _onStatsResponse(reader); break; case ResponseCodes.batteryVoltage: _onBatteryVoltageResponse(reader); break; + case ResponseCodes.currTime: + if (_getTimeCompleter != null && reader.remainingBytesCount >= 4) { + _getTimeCompleter?.complete(reader.readUInt32LE()); + _getTimeCompleter = null; + } + break; case ResponseCodes.exportContact: _onExportContactResponse(reader); break; + case ResponseCodes.signStart: + { + final completer = _signStartCompleter; + _signStartCompleter = null; + if (completer == null || completer.isCompleted) { + debugLog('[CONN] Ignoring unsolicited SIGN_START response'); + break; + } + // [reserved:1][maxSignDataLen:u32 LE] + if (reader.remainingBytesCount < 5) { + completer.completeError(const SignException('malformed_response', + 'SIGN_START response is shorter than 5 bytes')); + break; + } + reader.readByte(); // reserved + completer.complete(reader.readUInt32LE()); + break; + } + case ResponseCodes.signature: + { + final completer = _signatureCompleter; + _signatureCompleter = null; + if (completer == null || completer.isCompleted) { + debugLog('[CONN] Ignoring unsolicited SIGNATURE response'); + break; + } + // Guard BEFORE readBytes(64): a short frame must fast-fail here, + // otherwise it becomes a RangeError swallowed by the outer catch + // and the caller waits out the full 5s timeout for nothing. + if (reader.remainingBytesCount < 64) { + completer.completeError(SignException( + 'malformed_response', + 'SIGNATURE response carries ${reader.remainingBytesCount} ' + 'bytes, expected 64')); + break; + } + completer.complete(reader.readBytes(64)); + break; + } default: // Log unhandled response codes (like JS implementation) - debugLog('[CONN] Unhandled frame: code=$responseCode (0x${responseCode.toRadixString(16).padLeft(2, '0')})'); + debugLog( + '[CONN] Unhandled frame: code=$responseCode (0x${responseCode.toRadixString(16).padLeft(2, '0')})'); break; } } catch (e, stack) { debugError('[CONN] Error processing frame (${frame.length} bytes): $e'); - debugError('[CONN] Frame hex: ${_hexDump(frame)}'); + debugError('[CONN] Frame hex: $frameDump'); debugError('[CONN] Stack trace: $stack'); } } @@ -427,6 +665,54 @@ class MeshCoreConnection { return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' '); } + /// Complete every pending sign completer with an error. The SIGN_START + /// completer gets [startError] when supplied, so an ERR at that stage reads + /// as "this firmware has no CMD_SIGN" rather than a generic failure. + /// + /// Returns true when a sign was actually pending, so the caller can treat the + /// frame as consumed. + bool _failPendingSign(SignException error, {SignException? startError}) { + final start = _signStartCompleter; + final chunk = _signChunkOkCompleter; + final signature = _signatureCompleter; + if (start == null && chunk == null && signature == null) return false; + + _signStartCompleter = null; + _signChunkOkCompleter = null; + _signatureCompleter = null; + + if (start != null && !start.isCompleted) { + start.completeError(startError ?? error); + } + if (chunk != null && !chunk.isCompleted) chunk.completeError(error); + if (signature != null && !signature.isCompleted) { + signature.completeError(error); + } + return true; + } + + /// Tear down an in-flight sign on disconnect/dispose. + /// + /// Releasing the gate here is what keeps disconnect fast: the wardriving + /// channel deletion is a normal (gated) write, so leaving the gate closed + /// would park it behind the sign's 5s timeout while the link is dying. + void _abortPendingSign() { + final wasPending = _failPendingSign( + const SignException('aborted', 'Connection closed during sign')); + final gate = _signGate; + _signGate = null; + _signInProgress = false; + if (gate != null && !gate.isCompleted) gate.complete(); + if (wasPending) debugLog('[CONN] Aborted in-flight sign'); + } + + /// Abort any in-flight sign immediately, releasing the write gate so + /// teardown-time writes (advert-name restore, path-hash restore, flood + /// scope, channel deletion) are not parked behind the sign timeout. + /// Safe to call when no sign is running. The provider calls this FIRST + /// in its disconnect sequence (wired in a later task). + void abortPendingSign() => _abortPendingSign(); + void _onDeviceInfoResponse(BufferReader reader) { // Protocol format changed in v7/v8: // v1-v6: protoVer (1) + manufacturer C-string (64) + publicKey (32) @@ -446,10 +732,12 @@ class MeshCoreConnection { // Parse additional fields from v9+ firmware int? pathHashMode; + String? firmwareVersionString; if (reader.remainingBytesCount > 0) { - // FIRMWARE_VERSION: 20 bytes (skip) + // FIRMWARE_VERSION: 20-byte null-terminated C-string if (reader.remainingBytesCount >= 20) { - reader.readBytes(20); // firmware version string + firmwareVersionString = reader.readCString(20); + debugLog('[CONN] Firmware version string: $firmwareVersionString'); } // client_repeat: 1 byte (v9+, skip) @@ -460,7 +748,8 @@ class MeshCoreConnection { // path_hash_mode: 1 byte (v10+) if (reader.remainingBytesCount >= 1) { pathHashMode = reader.readByte(); - debugLog('[CONN] Device path hash mode: $pathHashMode (${pathHashMode + 1}-byte hops)'); + debugLog( + '[CONN] Device path hash mode: $pathHashMode (${pathHashMode + 1}-byte hops)'); } } @@ -471,6 +760,7 @@ class MeshCoreConnection { protocolVersion: firmwareVer, manufacturer: manufacturerModel, firmwareBuildDate: buildDate, + firmwareVersionString: firmwareVersionString, pathHashMode: pathHashMode, ); @@ -482,12 +772,12 @@ class MeshCoreConnection { reader.readBytes(32); // skip public key debugLog('[CONN] Manufacturer: $manufacturer'); - + final response = DeviceQueryResponse( protocolVersion: firmwareVer, manufacturer: manufacturer, ); - + _deviceQueryCompleter?.complete(response); _deviceQueryCompleter = null; } @@ -505,17 +795,24 @@ class MeshCoreConnection { final maxTxPower = reader.readByte(); final publicKey = reader.readBytes(32); - // Skip additional fields added in newer firmware versions - // These fields exist between publicKey and name + // Additional fields added in newer firmware versions, between publicKey and name + // (MeshCore companion protocol RESP_CODE_SELF_INFO). Older firmware omits this block. + // Encoding note: the wiki documents radioFreq as uint32 Hz, but real hardware reports + // it in kHz (a 910.525 MHz radio sends 910525); radioBw is uint32 Hz; SF/CR are bytes. + int? radioFreqKHz; + int? radioBwHz; + int? radioSf; + int? radioCr; if (reader.remainingBytesCount >= 22) { - reader.readInt32LE(); // advLat - reader.readInt32LE(); // advLon - reader.readBytes(3); // reserved - reader.readByte(); // manualAddContacts - reader.readUInt32LE(); // radioFreq - reader.readUInt32LE(); // radioBw - reader.readByte(); // radioSf - reader.readByte(); // radioCr + reader.readInt32LE(); // advLat + reader.readInt32LE(); // advLon + reader.readBytes(3); // reserved + reader.readByte(); // manualAddContacts + radioFreqKHz = + reader.readUInt32LE(); // radioFreq (kHz on real hardware) + radioBwHz = reader.readUInt32LE(); // radioBw (Hz) + radioSf = reader.readByte(); // radioSf + radioCr = reader.readByte(); // radioCr } // Read name from remaining bytes @@ -527,10 +824,19 @@ class MeshCoreConnection { maxTxPower: maxTxPower, publicKey: publicKey, name: name, + radioFreqKHz: radioFreqKHz, + radioBwHz: radioBwHz, + radioSf: radioSf, + radioCr: radioCr, ); _selfInfo = selfInfo; - debugLog('[CONN] SelfInfo received: name="${selfInfo.name}", publicKey=${selfInfo.publicKeyHex.substring(0, 16)}...'); + debugLog( + '[CONN] SelfInfo received: name="${selfInfo.name}", publicKey=${selfInfo.publicKeyHex.substring(0, 16)}..., radio=${selfInfo.radioConfigApi ?? "n/a"}'); + // Raw radio values straight off the device — surfaces the actual encoding in the + // downloadable debug log (diagnoses any future unit questions). + debugLog( + '[CONN] Radio raw: freqKHz=$radioFreqKHz bwHz=$radioBwHz sf=$radioSf cr=$radioCr → ${selfInfo.radioConfigApi ?? "n/a"}'); _selfInfoCompleter?.complete(selfInfo); _selfInfoCompleter = null; @@ -609,18 +915,42 @@ class MeshCoreConnection { _controlDataController.add((raw: raw, snr: snr, rssi: rssi)); } + void _onTraceDataPush(BufferReader reader) { + // 0x89 TraceData has NO snr/rssi prefix (unlike 0x88 LogRxData). + // The entire remaining payload is the trace response: + // [reserved][path_len][flags][tag:4][auth:4][path_hashes][path_snrs] + final raw = reader.readRemainingBytes(); + + debugLog('[CONN] Received trace data: ${raw.length} bytes'); + + _traceDataController.add(raw); + } + void _onStatsResponse(BufferReader reader) { // Stats response format (from web client): // + // Valid stats payload is 13 bytes. Some firmware versions send peer info + // frames on the same response code (0x18) at 82+ bytes — reject those. + if (reader.remainingBytesCount > 30) { + _statsCompleter?.complete(0); + _statsCompleter = null; + return; + } try { final statsType = reader.readByte(); if (statsType == StatsTypes.radio) { final noiseFloor = reader.readInt16LE(); // Skip remaining fields (lastRssi, lastSnr, txAirSecs, rxAirSecs) - _lastNoiseFloor = noiseFloor; - _noiseFloorController.add(noiseFloor); // Emit to stream - debugLog('[CONN] Noise floor updated: ${noiseFloor}dBm'); - _statsCompleter?.complete(noiseFloor); + if (noiseFloor == 0) { + // MeshCore 1.14.x AGC reset zeroes out noise floor briefly; discard + debugLog('[CONN] Noise floor reading is 0dBm (AGC reset), ignoring'); + _statsCompleter?.complete(0); + } else { + _lastNoiseFloor = noiseFloor; + _noiseFloorController.add(noiseFloor); // Emit to stream + debugLog('[CONN] Noise floor updated: ${noiseFloor}dBm'); + _statsCompleter?.complete(noiseFloor); + } } else { debugLog('[CONN] Unknown stats type: $statsType'); _statsCompleter?.complete(0); @@ -642,7 +972,8 @@ class MeshCoreConnection { // Consume any remaining bytes (firmware may send extended format) if (reader.remainingBytesCount > 0) { final extraBytes = reader.readRemainingBytes(); - debugLog('[CONN] Battery response has ${extraBytes.length} extra bytes (ignoring)'); + debugLog( + '[CONN] Battery response has ${extraBytes.length} extra bytes (ignoring)'); } _batteryController.add(percent); // Emit percentage to stream @@ -664,10 +995,13 @@ class MeshCoreConnection { void _onExportContactResponse(BufferReader reader) { try { final advertPacketBytes = reader.readRemainingBytes(); - final hexString = advertPacketBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); + final hexString = advertPacketBytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); final contactUri = 'meshcore://$hexString'; - debugLog('[CONN] Received export contact: ${contactUri.substring(0, 50)}...'); + debugLog( + '[CONN] Received export contact: ${contactUri.substring(0, 50)}...'); _exportContactCompleter?.complete(contactUri); _exportContactCompleter = null; @@ -678,9 +1012,47 @@ class MeshCoreConnection { } } + /// The ONE outbound funnel. Every frame this class sends goes through here. + /// + /// While a sign is in flight, non-sign frames wait for it to finish. That + /// matters because CMD_SIGN_DATA is acked with a bare OK (0x00) and so are + /// setFloodScope, setChannel, setPathHashMode, setAdvertName and setTxPower — + /// letting one of those onto the wire mid-sign means its OK is consumed as + /// the chunk ack and the handshake desynchronises. Sign's own frames pass + /// [isSignFrame] and bypass the gate. + Future _write(Uint8List bytes, {bool isSignFrame = false}) async { + if (!isSignFrame) { + final gate = _signGate; + if (gate != null && !gate.isCompleted) { + final code = bytes.isNotEmpty ? bytes[0] : -1; + debugLog('[CONN] Queuing command $code behind an in-progress sign'); + // The gate future NEVER completes with an error, so a queued command + // is delayed, never failed. + await gate.future; + } + } + await _transport.write(bytes); + } + /// Write frame to device Future _sendToRadio(BufferWriter data) async { - await _bluetooth.write(data.toBytes()); + await _write(data.toBytes()); + } + + /// Completes once an outbound non-sign write would not be parked. + /// + /// [_write] queues non-sign frames behind an in-progress sign, and that wait + /// is deliberately unbounded — a queued command is delayed, never failed. + /// Signing allows five seconds per protocol phase and the chunk phase loops, + /// so a caller working to a deadline that simply calls a send method can have + /// its frame reach the wire long after that deadline, with nothing left to + /// check by then. Awaiting this first moves the wait to a point where + /// abandoning is still free and leaves no half-built transmission behind. + Future awaitWritableState() async { + final gate = _signGate; + if (gate == null || gate.isCompleted) return; + debugLog('[CONN] Caller waiting out an in-progress sign before deciding'); + await gate.future; } // ============================================ @@ -700,7 +1072,8 @@ class MeshCoreConnection { /// Get device self info (includes public key) /// Reference: getSelfInfo() in connection.js - Future getSelfInfo({Duration timeout = const Duration(seconds: 5)}) async { + Future getSelfInfo( + {Duration timeout = const Duration(seconds: 5)}) async { _selfInfoCompleter = Completer(); // Save reference to future BEFORE sending command to avoid race condition @@ -728,18 +1101,51 @@ class MeshCoreConnection { data.writeByte(appTargetVer); await _sendToRadio(data); + // Send APP_START so device enters companion mode. + // Without this, some devices won't respond to the device query. + await sendCommandAppStart(); + return future.timeout( const Duration(seconds: 5), onTimeout: () => throw TimeoutException('Device query timed out'), ); } - /// Set device time + /// Set device time and await OK/ERROR response from device Future setDeviceTime(int epochSecs) async { + _setTimeCompleter = Completer(); + final future = _setTimeCompleter!.future; + final data = BufferWriter(); data.writeByte(CommandCodes.setDeviceTime); data.writeUInt32LE(epochSecs); await _sendToRadio(data); + + return future.timeout( + const Duration(seconds: 5), + onTimeout: () { + _setTimeCompleter = null; + debugWarn('[CONN] Time sync timed out - continuing anyway'); + }, + ); + } + + /// Query the device's current RTC clock (epoch seconds) + Future getDeviceTime() async { + _getTimeCompleter = Completer(); + final future = _getTimeCompleter!.future; + + final data = BufferWriter(); + data.writeByte(CommandCodes.getDeviceTime); + await _sendToRadio(data); + + return future.timeout( + const Duration(seconds: 5), + onTimeout: () { + _getTimeCompleter = null; + throw TimeoutException('getDeviceTime timed out'); + }, + ); } /// Set TX power @@ -779,11 +1185,12 @@ class MeshCoreConnection { final future = _channelInfoCompleter!.future; final data = BufferWriter(); - data.writeByte(CommandCodes.getChannel); // 31 (0x1F) + data.writeByte(CommandCodes.getChannel); // 31 (0x1F) data.writeByte(channelIdx); final bytes = data.toBytes(); - debugLog('[CONN] getChannel bytes: ${bytes.map((b) => '0x${b.toRadixString(16).padLeft(2, '0')}').join(' ')}'); - await _bluetooth.write(bytes); + debugLog( + '[CONN] getChannel bytes: ${bytes.map((b) => '0x${b.toRadixString(16).padLeft(2, '0')}').join(' ')}'); + await _write(bytes); return future.timeout( const Duration(seconds: 5), @@ -860,7 +1267,8 @@ class MeshCoreConnection { Future findChannelBySecret(Uint8List secret) async { final channels = await getChannels(); try { - return channels.firstWhere((channel) => _areBuffersEqual(channel.secret, secret)); + return channels + .firstWhere((channel) => _areBuffersEqual(channel.secret, secret)); } catch (e) { return null; // Not found } @@ -877,7 +1285,8 @@ class MeshCoreConnection { /// Send channel text message (for TX pings) /// Reference: sendCommandSendChannelTxtMsg in connection.js - Future sendChannelTextMessage(int txtType, int channelIdx, int senderTimestamp, String text) async { + Future sendChannelTextMessage( + int txtType, int channelIdx, int senderTimestamp, String text) async { _sentCompleter = Completer(); // Save reference to future BEFORE sending command to avoid race condition @@ -900,24 +1309,22 @@ class MeshCoreConnection { ); } - /// Send ping to #wardriving channel - /// Format: @[MapperBot] LAT, LON [power] - /// Reference: buildPayload() in wardrive.js - Future sendPing(double lat, double lon, double powerWatts) async { + /// Send a pre-composed TX body to the #wardriving channel. + /// The caller composes the body (privacy wire tag "MM:..." by default, or the + /// legacy "@[MapperBot] LAT, LON" when the user opts into broadcasting coords) + /// so the exact same string is used for both TxTracker echo matching and the + /// actual transmission. + /// Power is not included in the mesh message — it is sent per-ping in the API payload. + Future sendPing(String message) async { final channel = _wardrivingChannel; if (channel == null) { throw Exception('Wardriving channel not initialized'); } - // Format coordinates to 5 decimal places with comma separator - final coordsStr = '${lat.toStringAsFixed(5)}, ${lon.toStringAsFixed(5)}'; - // Format power as "X.Xw" (e.g., "1.0w", "0.3w") - final powerStr = '${powerWatts.toStringAsFixed(1)}w'; - final message = '@[MapperBot] $coordsStr [$powerStr]'; - debugLog('[CONN] Sending ping: $message'); final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; - await sendChannelTextMessage(TxtTypes.plain, channel.channelIndex, timestamp, message); + await sendChannelTextMessage( + TxtTypes.plain, channel.channelIndex, timestamp, message); } /// Send discovery request to find nearby repeaters/rooms @@ -945,16 +1352,59 @@ class MeshCoreConnection { '${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); final data = BufferWriter(); - data.writeByte(CommandCodes.sendControlData); // 0x37 - data.writeByte(DiscoveryConstants.discoverReqFlag); // 0x80 = DISCOVER_REQ - data.writeByte(DiscoveryConstants.typeFilterRepeaterRoom); // 0x0C = REPEATER | ROOM - data.writeBytes(tag); // 4-byte random tag - data.writeUInt32LE(0); // timestamp = 0 (discover all) + data.writeByte(CommandCodes.sendControlData); // 0x37 + data.writeByte(DiscoveryConstants.discoverReqFlag); // 0x80 = DISCOVER_REQ + data.writeByte( + DiscoveryConstants.typeFilterRepeaterRoom); // 0x0C = REPEATER | ROOM + data.writeBytes(tag); // 4-byte random tag + data.writeUInt32LE(0); // timestamp = 0 (discover all) await _sendToRadio(data); return tag; } + /// Send trace path to a specific repeater (targeted ping / zero-hop trace) + /// Returns the 4-byte tag used for matching the response + /// [hopBytes] controls trace ID size: 1, 2, or 4 bytes (bitshift encoding) + Future sendTracePath(Uint8List repeaterIdBytes, + {int hopBytes = 1}) async { + final random = Random.secure(); + final tag = Uint8List.fromList([ + random.nextInt(256), + random.nextInt(256), + random.nextInt(256), + random.nextInt(256), + ]); + + // Trace uses bitshift encoding: actual_bytes = 1 << path_sz + // 1 → path_sz=0, 2 → path_sz=1, 4 → path_sz=2 + final int pathSz; + switch (hopBytes) { + case 4: + pathSz = 2; + break; + case 2: + pathSz = 1; + break; + default: + pathSz = 0; + break; + } + final int flags = pathSz & 0x03; + + debugLog( + '[CONN] Sending trace to ${repeaterIdBytes.map((b) => b.toRadixString(16).padLeft(2, "0")).join("")} (traceBytes=$hopBytes, path_sz=$pathSz)'); + + final data = BufferWriter(); + data.writeByte(CommandCodes.sendTracePath); // 0x24 + data.writeBytes(tag); // 4-byte tag + data.writeUInt32LE(0); // auth_code = 0 + data.writeByte(flags); // flags with path_sz in bits 0-1 + data.writeBytes(repeaterIdBytes); // target repeater ID + await _sendToRadio(data); + return tag; + } + /// Get battery voltage Future getBatteryVoltage() async { final data = BufferWriter(); @@ -964,12 +1414,13 @@ class MeshCoreConnection { /// Export signed contact URI for API authentication /// Returns meshcore:// URI containing signed ADVERT packet - Future exportContact({Duration timeout = const Duration(seconds: 5)}) async { + Future exportContact( + {Duration timeout = const Duration(seconds: 5)}) async { _exportContactCompleter = Completer(); final future = _exportContactCompleter!.future; final data = BufferWriter(); - data.writeByte(CommandCodes.exportContact); // 0x11 + data.writeByte(CommandCodes.exportContact); // 0x11 await _sendToRadio(data); return future.timeout( @@ -978,6 +1429,93 @@ class MeshCoreConnection { ); } + /// Ask the radio to Ed25519-sign [data] with its device private key. + /// + /// Framing (byte-identical to the portal's meshcore.js, which minted every + /// existing `portal_pubkeys` row): + /// CMD_SIGN_START (0x21) -> RESP_SIGN_START (19) + /// CMD_SIGN_DATA (0x22) + <=128 data bytes -> OK (0x00), one per chunk + /// CMD_SIGN_FINISH (0x23) -> RESP_SIGNATURE (20) [64] + /// + /// Sign the RAW bytes, never their hex text. Throws [SignException] for + /// protocol failures, [TimeoutException] when the radio goes quiet, and + /// [StateError] when the connection is disposed or a sign is already running. + Future sign(Uint8List data, + {Duration timeout = const Duration(seconds: 5)}) async { + if (_disposed) { + throw StateError('Cannot sign on a disposed connection'); + } + if (_signInProgress) { + throw StateError('A sign is already in progress'); + } + + _signInProgress = true; + final gate = Completer(); + _signGate = gate; + debugLog('[CONN] sign: starting (${data.length} bytes)'); + + try { + // 1) SIGN_START -> maxSignDataLen + final startCompleter = Completer(); + _signStartCompleter = startCompleter; + final startFrame = BufferWriter()..writeByte(CommandCodes.signStart); + await _write(startFrame.toBytes(), isSignFrame: true); + final maxSignDataLen = await startCompleter.future.timeout( + timeout, + onTimeout: () => throw TimeoutException('sign: SIGN_START timed out'), + ); + debugLog('[CONN] sign: maxSignDataLen=$maxSignDataLen'); + + if (data.length > maxSignDataLen) { + throw SignException('data_too_long', + 'Payload is ${data.length} bytes, radio accepts $maxSignDataLen'); + } + + // 2) SIGN_DATA chunks, one OK each + final chunkSize = min(128, maxSignDataLen); + for (var offset = 0; offset < data.length; offset += chunkSize) { + final end = min(offset + chunkSize, data.length); + final okCompleter = Completer(); + _signChunkOkCompleter = okCompleter; + final chunkFrame = BufferWriter() + ..writeByte(CommandCodes.signData) + ..writeBytes(data.sublist(offset, end)); + await _write(chunkFrame.toBytes(), isSignFrame: true); + await okCompleter.future.timeout( + timeout, + onTimeout: () => throw TimeoutException('sign: chunk ack timed out'), + ); + debugLog('[CONN] sign: chunk acked (${end - offset} bytes)'); + } + + // 3) SIGN_FINISH -> signature + final sigCompleter = Completer(); + _signatureCompleter = sigCompleter; + final finishFrame = BufferWriter()..writeByte(CommandCodes.signFinish); + await _write(finishFrame.toBytes(), isSignFrame: true); + final signature = await sigCompleter.future.timeout( + timeout, + onTimeout: () => throw TimeoutException('sign: signature timed out'), + ); + + if (signature.length != 64) { + throw SignException('bad_signature_length', + 'Radio returned ${signature.length} bytes, expected 64'); + } + debugLog('[CONN] sign: signature received'); + return signature; + } finally { + // Clear on EVERY path (success, throw, timeout) or the next sign + // inherits a stale completer and hangs. + _signStartCompleter = null; + _signChunkOkCompleter = null; + _signatureCompleter = null; + _signInProgress = false; + if (identical(_signGate, gate)) _signGate = null; + if (!gate.isCompleted) gate.complete(); + } + } + /// Get radio statistics (noise floor) /// Reference: sendCommandGetStats in connection.js Future getStats(int statsType) async { @@ -1032,7 +1570,8 @@ class MeshCoreConnection { _noiseFloorFailCount++; debugLog('[CONN] Noise floor fetch failed ($_noiseFloorFailCount/3): $e'); if (_noiseFloorFailCount >= 3) { - debugLog('[CONN] Noise floor polling stopped after 3 consecutive failures'); + debugLog( + '[CONN] Noise floor polling stopped after 3 consecutive failures'); _stopNoiseFloorPolling(); } } finally { @@ -1100,14 +1639,18 @@ class MeshCoreConnection { /// Dispose of resources void dispose() { + _disposed = true; _stopNoiseFloorPolling(); _stopBatteryPolling(); + _abortPendingSign(); + _setTimeCompleter = null; _dataSubscription?.cancel(); _stepController.close(); _channelMessageController.close(); _rawDataController.close(); _logRxDataController.close(); _controlDataController.close(); + _traceDataController.close(); _noiseFloorController.close(); _batteryController.close(); } diff --git a/lib/services/meshcore/crypto_service.dart b/lib/services/meshcore/crypto_service.dart index 30da8862..ea6f559b 100644 --- a/lib/services/meshcore/crypto_service.dart +++ b/lib/services/meshcore/crypto_service.dart @@ -12,28 +12,43 @@ class CryptoService { /// Fixed key for "Public" channel (non-hashtag channels) /// From MeshCore default: 8b3387e9c5cdea6ac9e5edbaa115cd72 static final Uint8List publicChannelFixedKey = Uint8List.fromList([ - 0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a, - 0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72, + 0x8b, + 0x33, + 0x87, + 0xe9, + 0xc5, + 0xcd, + 0xea, + 0x6a, + 0xc9, + 0xe5, + 0xed, + 0xba, + 0xa1, + 0x15, + 0xcd, + 0x72, ]); /// Derive a 16-byte channel key from a channel name using SHA-256 - /// + /// /// Matches JS implementation: `sha256(channelName).subarray(0, 16)` - /// + /// /// @param channelName - Channel name (must start with # for hashtag channels) /// @returns 16-byte channel key /// @throws FormatException if channel name is invalid static Uint8List deriveChannelKey(String channelName) { debugLog('[CRYPTO] Deriving channel key for: $channelName'); - + // Validate channel name format: must start with # and contain only letters, numbers, and dashes if (!channelName.startsWith('#')) { - throw FormatException('Channel name must start with # (got: "$channelName")'); + throw FormatException( + 'Channel name must start with # (got: "$channelName")'); } - + // Normalize channel name to lowercase (MeshCore convention) final normalizedName = channelName.toLowerCase(); - + // Check that the part after # contains only letters, numbers, and dashes final nameWithoutHash = normalizedName.substring(1); if (!RegExp(r'^[a-z0-9-]+$').hasMatch(nameWithoutHash)) { @@ -42,16 +57,17 @@ class CryptoService { 'Only letters, numbers, and dashes are allowed.', ); } - + // Hash using SHA-256 final bytes = utf8.encode(normalizedName); final digest = sha256.convert(bytes); - + // Take the first 16 bytes of the hash as the channel key final channelKey = Uint8List.fromList(digest.bytes.sublist(0, 16)); - - debugLog('[CRYPTO] Channel key derived successfully (${channelKey.length} bytes)'); - + + debugLog( + '[CRYPTO] Channel key derived successfully (${channelKey.length} bytes)'); + return channelKey; } @@ -65,12 +81,13 @@ class CryptoService { final bytes = utf8.encode(normalizedName); final digest = sha256.convert(bytes); final scopeKey = Uint8List.fromList(digest.bytes.sublist(0, 16)); - debugLog('[CRYPTO] Scope key derived for "$normalizedName" (${scopeKey.length} bytes)'); + debugLog( + '[CRYPTO] Scope key derived for "$normalizedName" (${scopeKey.length} bytes)'); return scopeKey; } /// Get channel key for any channel (handles both Public and hashtag channels) - /// + /// /// @param channelName - Channel name (e.g., "Public", "#wardriving", "#testing") /// @returns 16-byte channel key static Uint8List getChannelKey(String channelName) { @@ -83,9 +100,9 @@ class CryptoService { } /// Compute channel hash from channel secret (first byte of SHA-256) - /// + /// /// Used for identifying echo packets that match our channel - /// + /// /// @param channelSecret - The 16-byte channel secret /// @returns Channel hash (first byte of SHA-256) static int computeChannelHash(Uint8List channelSecret) { @@ -94,9 +111,9 @@ class CryptoService { } /// Decrypt channel message using AES-ECB mode - /// + /// /// MeshCore uses AES-128-ECB for channel message encryption - /// + /// /// @param encryptedPayload - The encrypted message bytes /// @param channelKey - The 16-byte channel key /// @returns Decrypted message bytes @@ -105,17 +122,18 @@ class CryptoService { Uint8List channelKey, ) { debugLog('[CRYPTO] Decrypting message (${encryptedPayload.length} bytes)'); - + if (channelKey.length != 16) { - throw ArgumentError('Channel key must be 16 bytes (got ${channelKey.length})'); + throw ArgumentError( + 'Channel key must be 16 bytes (got ${channelKey.length})'); } - + try { // Create AES cipher in ECB mode final cipher = ECBBlockCipher(AESEngine()); final params = KeyParameter(channelKey); cipher.init(false, params); // false = decrypt mode - + // Decrypt the payload final decrypted = Uint8List(encryptedPayload.length); var offset = 0; @@ -137,7 +155,7 @@ class CryptoService { } /// Encrypt channel message using AES-ECB mode - /// + /// /// @param plaintext - The message bytes to encrypt /// @param channelKey - The 16-byte channel key /// @returns Encrypted message bytes @@ -146,29 +164,30 @@ class CryptoService { Uint8List channelKey, ) { debugLog('[CRYPTO] Encrypting message (${plaintext.length} bytes)'); - + if (channelKey.length != 16) { - throw ArgumentError('Channel key must be 16 bytes (got ${channelKey.length})'); + throw ArgumentError( + 'Channel key must be 16 bytes (got ${channelKey.length})'); } - + try { // Add PKCS7 padding final padded = _addPkcs7Padding(plaintext, 16); - + // Create AES cipher in ECB mode final cipher = ECBBlockCipher(AESEngine()); final params = KeyParameter(channelKey); cipher.init(true, params); // true = encrypt mode - + // Encrypt the payload final encrypted = Uint8List(padded.length); var offset = 0; - + while (offset < padded.length) { cipher.processBlock(padded, offset, encrypted, offset); offset += cipher.blockSize; } - + debugLog('[CRYPTO] Encrypted successfully (${encrypted.length} bytes)'); return encrypted; } catch (e) { @@ -189,9 +208,9 @@ class CryptoService { } /// Parse channel message to extract text content - /// + /// /// Decrypts and decodes the message, returning the text if printable - /// + /// /// @param encryptedPayload - The encrypted message bytes /// @param channelKey - The 16-byte channel key /// @returns Decoded text or null if not printable @@ -202,15 +221,17 @@ class CryptoService { try { final decrypted = decryptChannelMessage(encryptedPayload, channelKey); final text = utf8.decode(decrypted, allowMalformed: true); - + // Check if text is printable (contains mostly ASCII printable characters) - final printableCount = text.codeUnits.where((c) => c >= 32 && c <= 126).length; + final printableCount = + text.codeUnits.where((c) => c >= 32 && c <= 126).length; final printableRatio = printableCount / text.length; - + if (printableRatio > 0.8) { return text; } else { - debugWarn('[CRYPTO] Message not printable (${(printableRatio * 100).toStringAsFixed(1)}% printable)'); + debugWarn( + '[CRYPTO] Message not printable (${(printableRatio * 100).toStringAsFixed(1)}% printable)'); return null; } } catch (e) { diff --git a/lib/services/meshcore/disc_tracker.dart b/lib/services/meshcore/disc_tracker.dart index 30130139..688eac4d 100644 --- a/lib/services/meshcore/disc_tracker.dart +++ b/lib/services/meshcore/disc_tracker.dart @@ -31,7 +31,13 @@ class DiscTracker { /// Parameters: (node, isNew) - isNew is true for first time seeing this node void Function(DiscoveredNode node, bool isNew)? onNodeDiscovered; - DiscTracker({this.shouldIgnoreRepeater, this.disableRssiFilter = false}); + /// Number of bytes per hop in path hash (1, 2, or 3). Controls repeater ID length. + final int hopBytes; + + DiscTracker( + {this.shouldIgnoreRepeater, + this.disableRssiFilter = false, + this.hopBytes = 1}); /// Callback fired when discovery window completes void Function(List discoveredNodes)? onWindowComplete; @@ -42,10 +48,11 @@ class DiscTracker { /// @param windowDuration - How long to listen (default 7 seconds) void startTracking({ required Uint8List tag, - Duration windowDuration = const Duration(seconds: 5), + Duration windowDuration = const Duration(seconds: 7), }) { debugLog('[DISC] Starting discovery tracking'); - debugLog('[DISC] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); + debugLog( + '[DISC] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); isListening = true; startTime = DateTime.now(); @@ -55,12 +62,14 @@ class DiscTracker { _windowTimer?.cancel(); _windowTimer = Timer(windowDuration, _endWindow); - debugLog('[DISC] Discovery tracking window started (${windowDuration.inSeconds}s)'); + debugLog( + '[DISC] Discovery tracking window started (${windowDuration.inSeconds}s)'); } /// Stop tracking and return collected nodes List stopTracking() { - debugLog('[DISC] Stopping discovery tracking (discovered ${nodes.length} nodes)'); + debugLog( + '[DISC] Stopping discovery tracking (discovered ${nodes.length} nodes)'); final result = nodes.values.toList(); @@ -113,14 +122,16 @@ class DiscTracker { // Check if this is a discovery response (upper nibble = 0x90) if (upperNibble != DiscoveryConstants.discoverRespFlag) { - debugLog('[DISC] Not a discovery response: flags=0x${flags.toRadixString(16).padLeft(2, '0')}'); + debugLog( + '[DISC] Not a discovery response: flags=0x${flags.toRadixString(16).padLeft(2, '0')}'); return false; } // Check node type (lower nibble must be REPEATER=0x01 or ROOM=0x02) if (lowerNibble != DiscoveryConstants.nodeTypeRepeater && lowerNibble != DiscoveryConstants.nodeTypeRoom) { - debugLog('[DISC] Ignoring node type: 0x${lowerNibble.toRadixString(16)}'); + debugLog( + '[DISC] Ignoring node type: 0x${lowerNibble.toRadixString(16)}'); return false; } @@ -132,28 +143,36 @@ class DiscTracker { // Extract public key (bytes 7-38) final pubkey = rawBytes.sublist(7, 39); - final pubkeyHex = pubkey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + final pubkeyHex = pubkey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join('') + .toUpperCase(); - // Get repeater ID (first 2 hex chars = first byte) - final repeaterId = pubkeyHex.substring(0, 2); + // Get repeater ID (first N hex chars based on hopBytes setting) + final repeaterId = pubkeyHex.substring(0, hopBytes * 2); // Check if this repeater should be ignored (user carpeater filter) if (shouldIgnoreRepeater != null && shouldIgnoreRepeater!(repeaterId)) { - debugLog('[DISC] Ignoring repeater $repeaterId (user carpeater filter)'); + debugLog( + '[DISC] Ignoring repeater $repeaterId (user carpeater filter)'); return false; } // Check RSSI (carpeater failsafe) if (disableRssiFilter) { - debugLog('[DISC] RSSI filter disabled by user, skipping carpeater check'); + debugLog( + '[DISC] RSSI filter disabled by user, skipping carpeater check'); } else if (PacketValidator.isCarpeater(localRssi)) { - debugLog('[DISC] ❌ DROPPED: RSSI too strong ($localRssi ≥ ${PacketValidator.maxRssiThreshold}) ' + debugLog( + '[DISC] ❌ DROPPED: RSSI too strong ($localRssi ≥ ${PacketValidator.maxRssiThreshold}) ' '- possible carpeater, repeater=$repeaterId'); onCarpeaterDrop?.call(repeaterId, 'RSSI too strong ($localRssi dBm)'); return false; } - final nodeType = lowerNibble == DiscoveryConstants.nodeTypeRepeater ? 'REPEATER' : 'ROOM'; + final nodeType = lowerNibble == DiscoveryConstants.nodeTypeRepeater + ? 'REPEATER' + : 'ROOM'; debugLog('[DISC] Received response from $repeaterId ($nodeType): ' 'localSnr=${localSnr.toStringAsFixed(2)}, remoteSnr=${remoteSnr.toStringAsFixed(2)}, ' @@ -209,12 +228,14 @@ class DiscTracker { /// Discovered node data class DiscoveredNode { - final String repeaterId; // First 2 hex chars of pubkey (e.g., "77", "4E") - final int nodeType; // 0x01 = REPEATER, 0x02 = ROOM - final double localSnr; // SNR as seen by local device (dB) - final int localRssi; // RSSI as seen by local device (dBm) - final double remoteSnr; // SNR as seen by remote node (dB) - final String pubkeyFull; // Full 32-byte public key as hex (local only, not sent to API) + final String + repeaterId; // First N hex chars of pubkey based on hopBytes (e.g., "4E", "4E7A", "4E7A3B") + final int nodeType; // 0x01 = REPEATER, 0x02 = ROOM + final double localSnr; // SNR as seen by local device (dB) + final int localRssi; // RSSI as seen by local device (dBm) + final double remoteSnr; // SNR as seen by remote node (dB) + final String + pubkeyFull; // Full 32-byte public key as hex (local only, not sent to API) DiscoveredNode({ required this.repeaterId, @@ -226,8 +247,10 @@ class DiscoveredNode { }); /// Get node type as display string - String get nodeTypeName => nodeType == DiscoveryConstants.nodeTypeRepeater ? 'REPEATER' : 'ROOM'; + String get nodeTypeName => + nodeType == DiscoveryConstants.nodeTypeRepeater ? 'REPEATER' : 'ROOM'; /// Get short display label: "(R)" for REPEATER, "(RM)" for ROOM - String get nodeTypeLabel => nodeType == DiscoveryConstants.nodeTypeRepeater ? '(R)' : '(RM)'; + String get nodeTypeLabel => + nodeType == DiscoveryConstants.nodeTypeRepeater ? '(R)' : '(RM)'; } diff --git a/lib/services/meshcore/packet_metadata.dart b/lib/services/meshcore/packet_metadata.dart index a6dcc0a5..6c0f41ab 100644 --- a/lib/services/meshcore/packet_metadata.dart +++ b/lib/services/meshcore/packet_metadata.dart @@ -67,14 +67,17 @@ class PacketMetadata { final int rssi = data['lastRssi'] as int; // Dump raw packet for debugging - final rawHex = raw.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(' '); + final rawHex = raw + .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(' '); debugLog('[RX PARSE] RAW Packet (${raw.length} bytes): $rawHex'); // Extract header byte from raw[0] final int header = raw[0]; final int routeType = header & PacketHeader.routeMask; - debugLog('[RX PARSE] Header: 0x${header.toRadixString(16).padLeft(2, '0')}, Route type: $routeType'); + debugLog( + '[RX PARSE] Header: 0x${header.toRadixString(16).padLeft(2, '0')}, Route type: $routeType'); // Calculate offset for Path Length based on route type // Reference: wardrive.js lines 3168-3173 @@ -92,7 +95,8 @@ class PacketMetadata { final int pathHashCount = pathLenRaw & 63; final int pathByteLen = pathHashCount * pathHashSize; - debugLog('[RX PARSE] Path length offset: $pathLengthOffset, pathLenRaw: 0x${pathLenRaw.toRadixString(16).padLeft(2, '0')}, ' + debugLog( + '[RX PARSE] Path length offset: $pathLengthOffset, pathLenRaw: 0x${pathLenRaw.toRadixString(16).padLeft(2, '0')}, ' 'pathHashSize: $pathHashSize bytes/hop, pathHashCount: $pathHashCount hops, pathByteLen: $pathByteLen'); // Path data starts after path length byte @@ -104,9 +108,14 @@ class PacketMetadata { // Extract encrypted payload after path data final int payloadOffset = pathDataOffset + pathByteLen; + if (payloadOffset > raw.length) { + throw RangeError( + 'Packet too short: payload offset $payloadOffset exceeds packet length ${raw.length}'); + } final Uint8List encryptedPayload = raw.sublist(payloadOffset); - debugLog('[RX PARSE] Parsed metadata: header=0x${header.toRadixString(16).padLeft(2, '0')}, ' + debugLog( + '[RX PARSE] Parsed metadata: header=0x${header.toRadixString(16).padLeft(2, '0')}, ' 'pathHashSize=$pathHashSize, pathHashCount=$pathHashCount, ' 'firstHop=${pathHashCount > 0 ? _bytesToHexStatic(pathBytes.sublist(0, pathHashSize)) : 'null'}, ' 'lastHop=${pathHashCount > 0 ? _bytesToHexStatic(pathBytes.sublist(pathBytes.length - pathHashSize)) : 'null'}, ' @@ -152,16 +161,25 @@ class PacketMetadata { /// Check if packet is GROUP_TEXT (channel message, header 0x15) bool get isGroupText { // Extract payload type from header - final payloadType = (header >> PacketHeader.typeShift) & PacketHeader.typeMask; + final payloadType = + (header >> PacketHeader.typeShift) & PacketHeader.typeMask; return payloadType == PayloadType.grpTxt; } /// Check if packet is ADVERT (node advertisement, header 0x11) bool get isAdvert { - final payloadType = (header >> PacketHeader.typeShift) & PacketHeader.typeMask; + final payloadType = + (header >> PacketHeader.typeShift) & PacketHeader.typeMask; return payloadType == PayloadType.advert; } + /// Check if packet is TRACE (trace path response, header 0x26) + bool get isTrace { + final payloadType = + (header >> PacketHeader.typeShift) & PacketHeader.typeMask; + return payloadType == PayloadType.trace; + } + /// Get first hop as hex string (for TX tracking keys) /// Returns multi-byte hex (2/4/6/8 chars depending on pathHashSize) String? get firstHopHex { @@ -186,12 +204,18 @@ class PacketMetadata { /// Convert N bytes to uppercase hex string String _bytesToHex(Uint8List bytes) { - return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + return bytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join('') + .toUpperCase(); } /// Static version for use in factory constructor static String _bytesToHexStatic(Uint8List bytes) { - return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase(); + return bytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join('') + .toUpperCase(); } @override diff --git a/lib/services/meshcore/packet_parser.dart b/lib/services/meshcore/packet_parser.dart index 6dfee5b6..84842eea 100644 --- a/lib/services/meshcore/packet_parser.dart +++ b/lib/services/meshcore/packet_parser.dart @@ -253,12 +253,12 @@ class ChannelInfo { final channelIndex = reader.readByte(); final name = reader.readCString(32); final remainingBytes = reader.remainingBytesCount; - + // Protocol v8 uses 16-byte (128-bit) keys, v1 used 32-byte keys if (remainingBytes != 16 && remainingBytes != 32) { throw Exception('ChannelInfo has unexpected key length: $remainingBytes'); } - + return ChannelInfo( channelIndex: channelIndex, name: name, diff --git a/lib/services/meshcore/packet_validator.dart b/lib/services/meshcore/packet_validator.dart index 8cfd5d0b..0b6af86f 100644 --- a/lib/services/meshcore/packet_validator.dart +++ b/lib/services/meshcore/packet_validator.dart @@ -12,7 +12,7 @@ class PacketValidator { /// Packets stronger than this are likely from co-located repeaters /// Reference: MAX_RX_RSSI_THRESHOLD in wardrive.js static const int maxRssiThreshold = -30; - + /// Minimum printable character ratio (60%) /// Lowered from 90% to allow emojis and Unicode in messages /// Still filters out completely corrupted data @@ -24,33 +24,40 @@ class PacketValidator { /// When true, skip RSSI carpeater check (user setting) final bool disableRssiFilter; - PacketValidator({required this.allowedChannels, this.disableRssiFilter = false}); + PacketValidator( + {required this.allowedChannels, this.disableRssiFilter = false}); /// Validate packet for RX wardriving /// Returns ValidationResult with success/failure and reason /// [skipRssiCheck] - When true, skip the RSSI carpeater check (used for CARpeater pass-through) - Future validate(PacketMetadata metadata, {bool skipRssiCheck = false}) async { + Future validate(PacketMetadata metadata, + {bool skipRssiCheck = false}) async { try { // Log packet for debugging final rawHex = metadata.raw .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) .join(' '); debugLog('[RX FILTER] ========== VALIDATING PACKET =========='); - debugLog('[RX FILTER] Raw packet (${metadata.raw.length} bytes): $rawHex'); - debugLog('[RX FILTER] Header: 0x${metadata.header.toRadixString(16).padLeft(2, '0')} | ' + debugLog( + '[RX FILTER] Raw packet (${metadata.raw.length} bytes): $rawHex'); + debugLog( + '[RX FILTER] Header: 0x${metadata.header.toRadixString(16).padLeft(2, '0')} | ' 'PathHashCount: ${metadata.pathHashCount} | SNR: ${metadata.snr}'); // VALIDATION 1: Check RSSI (carpeater filter) if (skipRssiCheck) { debugLog('[RX FILTER] RSSI check skipped (CARpeater pass-through)'); } else if (disableRssiFilter) { - debugLog('[RX FILTER] RSSI filter disabled by user, skipping carpeater check'); + debugLog( + '[RX FILTER] RSSI filter disabled by user, skipping carpeater check'); } else if (isCarpeater(metadata.rssi)) { - debugLog('[RX FILTER] ❌ DROPPED: RSSI too strong (${metadata.rssi} ≥ $maxRssiThreshold) - ' + debugLog( + '[RX FILTER] ❌ DROPPED: RSSI too strong (${metadata.rssi} ≥ $maxRssiThreshold) - ' 'possible carpeater (RSSI failsafe)'); return ValidationResult.failed('carpeater-rssi'); } else { - debugLog('[RX FILTER] ✓ RSSI OK (${metadata.rssi} < $maxRssiThreshold)'); + debugLog( + '[RX FILTER] ✓ RSSI OK (${metadata.rssi} < $maxRssiThreshold)'); } // VALIDATION 2: Check packet type @@ -83,7 +90,8 @@ class PacketValidator { // Extract channel hash final channelHash = metadata.channelHash!; - debugLog('[RX FILTER] Channel hash: 0x${channelHash.toRadixString(16).padLeft(2, '0')}'); + debugLog( + '[RX FILTER] Channel hash: 0x${channelHash.toRadixString(16).padLeft(2, '0')}'); // Check if channel is in allowed list final channelInfo = allowedChannels[channelHash]; @@ -109,7 +117,8 @@ class PacketValidator { // Decrypted structure: [4 bytes timestamp][1 byte flags][message text] // Skip first 5 bytes to get the actual message if (decryptedBytes.length < 5) { - debugLog('[RX FILTER] ❌ DROPPED: Decrypted data too short (${decryptedBytes.length} bytes, need 5+)'); + debugLog( + '[RX FILTER] ❌ DROPPED: Decrypted data too short (${decryptedBytes.length} bytes, need 5+)'); return ValidationResult.failed('decrypted too short'); } @@ -122,21 +131,24 @@ class PacketValidator { // Remove trailing nulls and trim plaintext = plaintext.replaceAll(RegExp(r'\x00+$'), '').trim(); } catch (e) { - debugLog('[RX FILTER] ❌ DROPPED: Failed to convert decrypted bytes to string'); + debugLog( + '[RX FILTER] ❌ DROPPED: Failed to convert decrypted bytes to string'); return ValidationResult.failed('decode failed'); } // Sanitize for logging: remove replacement characters to avoid Flutter UTF-8 warnings final sanitizedForLog = plaintext - .replaceAll('\uFFFD', '') // Remove replacement characters - .replaceAll(RegExp(r'[^\x20-\x7E]'), ''); // Keep only printable ASCII - final logPreview = sanitizedForLog.substring(0, sanitizedForLog.length.clamp(0, 60)); + .replaceAll('\uFFFD', '') // Remove replacement characters + .replaceAll(RegExp(r'[^\x20-\x7E]'), ''); // Keep only printable ASCII + final logPreview = + sanitizedForLog.substring(0, sanitizedForLog.length.clamp(0, 60)); debugLog('[RX FILTER] Decrypted message (${plaintext.length} chars): ' '"$logPreview${sanitizedForLog.length > 60 ? '...' : ''}"'); // Check printable ratio final printableRatio = getPrintableRatio(plaintext); - debugLog('[RX FILTER] Printable ratio: ${(printableRatio * 100).toFixed(1)}% ' + debugLog( + '[RX FILTER] Printable ratio: ${(printableRatio * 100).toFixed(1)}% ' '(threshold: ${(minPrintableRatio * 100).toFixed(1)}%)'); if (printableRatio < minPrintableRatio) { @@ -163,7 +175,8 @@ class PacketValidator { return ValidationResult.failed(nameResult.reason); } - debugLog('[RX FILTER] ✅ KEPT: ADVERT passed all validations (name="${nameResult.name}")'); + debugLog( + '[RX FILTER] ✅ KEPT: ADVERT passed all validations (name="${nameResult.name}")'); return ValidationResult.success(); } @@ -172,6 +185,18 @@ class PacketValidator { return rssi >= maxRssiThreshold; } + /// Check if a hop hex string matches a stored CARpeater ID using prefix truncation. + /// The stored ID is always 3-byte (6 hex chars). Incoming hop IDs vary by the + /// packet's path hash size (1, 2, or 3 bytes). Compares the shorter prefix. + /// Also handles legacy shorter stored IDs from before the 3-byte requirement. + static bool isCarpeaterIdMatch(String hopHex, String storedId) { + final hop = hopHex.toUpperCase(); + final stored = storedId.toUpperCase(); + final compareLen = hop.length < stored.length ? hop.length : stored.length; + if (compareLen == 0) return false; + return hop.substring(0, compareLen) == stored.substring(0, compareLen); + } + /// Calculate ratio of printable ASCII characters (32-126) static double getPrintableRatio(String text) { if (text.isEmpty) return 0.0; @@ -187,7 +212,6 @@ class PacketValidator { return printableCount / text.length; } - /// Parse ADVERT packet name field /// Reference: parseAdvertName() in wardrive.js lines 3353-3419 static AdvertNameResult parseAdvertName(Uint8List payload) { @@ -209,7 +233,8 @@ class PacketValidator { // Read flags byte from appData final flags = payload[appDataOffset]; - debugLog('[RX FILTER] ADVERT flags: 0x${flags.toRadixString(16).padLeft(2, '0')}'); + debugLog( + '[RX FILTER] ADVERT flags: 0x${flags.toRadixString(16).padLeft(2, '0')}'); // Flag masks (from advert.js) const advNameMask = 0x80; @@ -247,7 +272,8 @@ class PacketValidator { // Remove trailing nulls and whitespace name = name.replaceAll(RegExp(r'\x00+$'), '').trim(); - debugLog('[RX FILTER] ADVERT name extracted: "$name" (${name.length} chars)'); + debugLog( + '[RX FILTER] ADVERT name extracted: "$name" (${name.length} chars)'); if (name.isEmpty) { return const AdvertNameResult( @@ -259,7 +285,8 @@ class PacketValidator { // Check if name is printable (use same threshold as messages) final printableRatio = getPrintableRatio(name); - debugLog('[RX FILTER] ADVERT name printable ratio: ${(printableRatio * 100).toFixed(1)}%'); + debugLog( + '[RX FILTER] ADVERT name printable ratio: ${(printableRatio * 100).toFixed(1)}%'); if (printableRatio < minPrintableRatio) { return AdvertNameResult( diff --git a/lib/services/meshcore/protocol_constants.dart b/lib/services/meshcore/protocol_constants.dart index 3dee89f4..ba4bb0f9 100644 --- a/lib/services/meshcore/protocol_constants.dart +++ b/lib/services/meshcore/protocol_constants.dart @@ -5,7 +5,7 @@ class ProtocolConstants { ProtocolConstants._(); /// Supported companion protocol version - static const int supportedCompanionProtocolVersion = 1; + static const int supportedCompanionProtocolVersion = 4; /// Serial frame types static const int serialFrameTypeIncoming = 0x3e; // ">" @@ -18,12 +18,14 @@ class BleUuids { /// Nordic UART Service UUID static const String serviceUuid = '6E400001-B5A3-F393-E0A9-E50E24DCCA9E'; - + /// RX Characteristic (we write to this, device reads from it) - static const String characteristicRxUuid = '6E400002-B5A3-F393-E0A9-E50E24DCCA9E'; - + static const String characteristicRxUuid = + '6E400002-B5A3-F393-E0A9-E50E24DCCA9E'; + /// TX Characteristic (device writes to this, we read from it) - static const String characteristicTxUuid = '6E400003-B5A3-F393-E0A9-E50E24DCCA9E'; + static const String characteristicTxUuid = + '6E400003-B5A3-F393-E0A9-E50E24DCCA9E'; } /// Command codes sent to device @@ -63,7 +65,8 @@ class CommandCodes { static const int signData = 34; static const int signFinish = 35; static const int sendTracePath = 36; - static const int sendControlData = 55; // 0x37 - CMD_SEND_CONTROL_DATA (discovery) + static const int sendControlData = + 55; // 0x37 - CMD_SEND_CONTROL_DATA (discovery) static const int setOtherParams = 38; static const int sendTelemetryReq = 39; static const int setFloodScope = 54; // 0x36 - CMD_SET_FLOOD_SCOPE @@ -115,7 +118,8 @@ class PushCodes { static const int newAdvert = 0x8A; static const int telemetryResponse = 0x8B; static const int binaryResponse = 0x8C; - static const int controlData = 0x8E; // PUSH_CODE_CONTROL_DATA (discovery response) + static const int controlData = + 0x8E; // PUSH_CODE_CONTROL_DATA (discovery response) } /// Text message types @@ -140,11 +144,11 @@ class StatsTypes { class PacketHeader { PacketHeader._(); - static const int routeMask = 0x03; // 2-bits + static const int routeMask = 0x03; // 2-bits static const int typeShift = 2; - static const int typeMask = 0x0F; // 4-bits + static const int typeMask = 0x0F; // 4-bits static const int verShift = 6; - static const int verMask = 0x03; // 2-bits + static const int verMask = 0x03; // 2-bits } /// Route types diff --git a/lib/services/meshcore/rx_logger.dart b/lib/services/meshcore/rx_logger.dart index 9c464b3a..b4187e9c 100644 --- a/lib/services/meshcore/rx_logger.dart +++ b/lib/services/meshcore/rx_logger.dart @@ -9,14 +9,14 @@ import 'packet_validator.dart'; /// Reference: handleRxLogging() + handleRxBatching() in wardrive.js (lines 3812-4140) class RxLogger { bool isWardriving = false; - + /// Map of repeaterId (hex) -> RxBatch final Map _batchBuffer = {}; - + /// Configuration constants static const int batchDistanceMeters = 25; static const Duration batchTimeout = Duration(seconds: 30); - + /// Callback for batched/finalized RX entries (API queue posting) final Future Function(RxApiEntry) onRxEntry; @@ -34,6 +34,12 @@ class RxLogger { /// Called with repeater ID and reason when a packet is dropped due to carpeater detection final void Function(String repeaterId, String reason)? onCarpeaterDrop; + /// Callback fired (throttled to once per 30s) when an otherwise-valid RX + /// packet is dropped purely because no GPS fix is available. Lets the UI + /// explain why detection went quiet instead of looking offline (#340). + final void Function()? onNoGpsDrop; + DateTime? _lastNoGpsNotify; + /// CARpeater prefix — when set, multi-hop packets with this firstHop are stripped /// to report the underlying repeater with null SNR/RSSI String? carpeaterPrefix; @@ -44,6 +50,7 @@ class RxLogger { required this.getGpsLocation, this.shouldIgnoreRepeater, this.onCarpeaterDrop, + this.onNoGpsDrop, this.carpeaterPrefix, }); @@ -67,14 +74,15 @@ class RxLogger { PacketValidator validator, ) async { if (!isWardriving) return false; - + try { debugLog('[RX LOG] Processing packet for passive logging'); - + // VALIDATION: Check path length (need at least one hop) // Packets with no path are direct transmissions and don't provide repeater coverage info if (metadata.pathHashCount == 0) { - debugLog('[RX LOG] Ignoring: no path (direct transmission, not via repeater)'); + debugLog( + '[RX LOG] Ignoring: no path (direct transmission, not via repeater)'); return false; } @@ -88,7 +96,8 @@ class RxLogger { // CARpeater check: the carpeater is co-located with us, so it only // appears as the last hop (the delivery repeater) on RX packets - if (carpeaterPrefix != null && lastHopHex == carpeaterPrefix!.toUpperCase()) { + if (carpeaterPrefix != null && + PacketValidator.isCarpeaterIdMatch(lastHopHex, carpeaterPrefix!)) { if (metadata.pathHashCount < 2) { debugLog('[RX LOG] CARpeater pass-through: single-hop, dropping'); return false; @@ -98,7 +107,8 @@ class RxLogger { carpeaterStripped = true; reportedSnr = null; reportedRssi = null; - debugLog('[RX LOG] CARpeater pass-through: stripped $lastHopHex, reporting underlying repeater $repeaterId'); + debugLog( + '[RX LOG] CARpeater pass-through: stripped $lastHopHex, reporting underlying repeater $repeaterId'); } else { repeaterId = lastHopHex; } @@ -107,6 +117,14 @@ class RxLogger { final gpsLocation = getGpsLocation(); if (gpsLocation == null) { debugLog('[RX LOG] No GPS fix available, skipping entry'); + // Surface the reason at most once per 30s so a missing GPS fix doesn't + // look like the app has gone offline despite an active connection (#340). + final now = DateTime.now(); + if (_lastNoGpsNotify == null || + now.difference(_lastNoGpsNotify!) > const Duration(seconds: 30)) { + _lastNoGpsNotify = now; + onNoGpsDrop?.call(); + } return false; } @@ -114,14 +132,18 @@ class RxLogger { // Must run before RSSI check so user never sees confusing "RSSI too strong" // errors for a device they told the app to ignore // Skip for CARpeater pass-through (CARpeater itself was already handled) - if (!carpeaterStripped && shouldIgnoreRepeater != null && shouldIgnoreRepeater!(repeaterId)) { - debugLog('[RX LOG] ❌ Ignoring repeater $repeaterId (user carpeater filter)'); + if (!carpeaterStripped && + shouldIgnoreRepeater != null && + shouldIgnoreRepeater!(repeaterId)) { + debugLog( + '[RX LOG] ❌ Ignoring repeater $repeaterId (user carpeater filter)'); return false; } // PACKET FILTER: Validate packet before logging // Skip RSSI check for CARpeater pass-through - final validation = await validator.validate(metadata, skipRssiCheck: carpeaterStripped); + final validation = + await validator.validate(metadata, skipRssiCheck: carpeaterStripped); if (!validation.valid) { final rawHex = metadata.raw .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) @@ -131,16 +153,27 @@ class RxLogger { // Log carpeater drops to error log (without auto-switching) if (validation.reason == 'carpeater-rssi') { - onCarpeaterDrop?.call(repeaterId, 'RSSI too strong (${metadata.rssi} dBm)'); + onCarpeaterDrop?.call( + repeaterId, 'RSSI too strong (${metadata.rssi} dBm)'); } return false; } - debugLog('[RX LOG] Packet heard via ${carpeaterStripped ? 'underlying' : 'last'} hop: $repeaterId, ' + debugLog( + '[RX LOG] Packet heard via ${carpeaterStripped ? 'underlying' : 'last'} hop: $repeaterId, ' 'SNR=$reportedSnr, path_length=${metadata.pathHashCount}${carpeaterStripped ? ' (CARpeater stripped)' : ''}'); debugLog('[RX LOG] ✅ Packet validated and passed filter'); + // Build display path: full hop chain, minus the last hop when it was a + // carpeater pass-through (so the user's own carpeater is not shown). + final displayHopCount = carpeaterStripped + ? metadata.pathHashCount - 1 + : metadata.pathHashCount; + final displayHops = [ + for (var i = 0; i < displayHopCount; i++) metadata.getHopHex(i)!, + ]; + // Create observation for this packet final observation = RxObservation( repeaterId: repeaterId, @@ -152,6 +185,7 @@ class RxLogger { lon: gpsLocation.lon, timestamp: DateTime.now(), metadata: metadata, + displayHops: displayHops, ); // Handle tracking for API (best SNR with distance trigger) @@ -164,6 +198,7 @@ class RxLogger { header: metadata.header, currentLocation: gpsLocation, metadata: metadata, + displayHops: displayHops, ); // Only fire immediate callback if this observation was actually kept @@ -172,15 +207,17 @@ class RxLogger { // IMPORTANT: Use the batch's bestObservation which has the FIRST location // where we heard this repeater, not the current GPS location. // This ensures map pins stay at the original location. - final batchedObservation = _batchBuffer[repeaterId]?.bestObservation ?? observation; + final batchedObservation = + _batchBuffer[repeaterId]?.bestObservation ?? observation; onObservation?.call(batchedObservation); debugLog('[RX LOG] ✅ Observation kept in batch: repeater=$repeaterId, ' 'snr=${batchedObservation.snr ?? 'null'}, location=${batchedObservation.lat.toStringAsFixed(5)},${batchedObservation.lon.toStringAsFixed(5)}'); } else { - debugLog('[RX LOG] ⏭️ Observation ignored (worse SNR): repeater=$repeaterId, ' + debugLog( + '[RX LOG] ⏭️ Observation ignored (worse SNR): repeater=$repeaterId, ' 'snr=$reportedSnr, current_best=${_batchBuffer[repeaterId]?.bestObservation.snr}'); } - + return true; } catch (error, stackTrace) { debugError('[RX LOG] Error processing passive RX: $error'); @@ -200,6 +237,7 @@ class RxLogger { required int header, required ({double lat, double lon}) currentLocation, required PacketMetadata metadata, + required List displayHops, }) async { // Get or create buffer entry for this repeater RxBatch? buffer = _batchBuffer[repeaterId]; @@ -219,11 +257,13 @@ class RxLogger { lon: currentLocation.lon, timestamp: DateTime.now(), metadata: metadata, + displayHops: displayHops, ), ); _batchBuffer[repeaterId] = buffer; wasKept = true; // New repeater, observation is kept - debugLog('[RX BATCH] First observation for repeater $repeaterId: SNR=$snr'); + debugLog( + '[RX BATCH] First observation for repeater $repeaterId: SNR=$snr'); // Start 30-second timeout timer for this repeater buffer.timeoutTimer = Timer(batchTimeout, () { @@ -250,10 +290,11 @@ class RxLogger { rssi: rssi, pathLength: pathLength, header: header, - lat: buffer.firstLocation.lat, // Keep original location - lon: buffer.firstLocation.lon, // Keep original location + lat: buffer.firstLocation.lat, // Keep original location + lon: buffer.firstLocation.lon, // Keep original location timestamp: DateTime.now(), metadata: metadata, + displayHops: displayHops, ); wasKept = true; // Better SNR, observation is kept } else { @@ -276,7 +317,8 @@ class RxLogger { '(threshold=${batchDistanceMeters}m)'); if (distance >= batchDistanceMeters) { - debugLog('[RX BATCH] Distance threshold met for repeater $repeaterId, flushing'); + debugLog( + '[RX BATCH] Distance threshold met for repeater $repeaterId, flushing'); await _flushRepeater(repeaterId); } @@ -285,43 +327,45 @@ class RxLogger { /// Check all active RX batches for distance threshold on GPS position update /// Called from GPS service when position changes - Future checkDistanceTriggers(({double lat, double lon}) currentLocation) async { + Future checkDistanceTriggers( + ({double lat, double lon}) currentLocation) async { if (_batchBuffer.isEmpty) { return; // No active batches to check } - debugLog('[RX BATCH] Checking ${_batchBuffer.length} active batch(es) for distance trigger'); - + debugLog( + '[RX BATCH] Checking ${_batchBuffer.length} active batch(es) for distance trigger'); + final repeatersToFlush = []; - + // Check each active batch for (final entry in _batchBuffer.entries) { final repeaterId = entry.key; final buffer = entry.value; - + final distance = _calculateHaversineDistance( currentLocation.lat, currentLocation.lon, buffer.firstLocation.lat, buffer.firstLocation.lon, ); - + debugLog('[RX BATCH] Distance check for repeater $repeaterId: ' '${distance.toStringAsFixed(2)}m from first observation ' '(threshold=${batchDistanceMeters}m)'); - + if (distance >= batchDistanceMeters) { debugLog('[RX BATCH] Distance threshold met for repeater $repeaterId, ' 'marking for flush'); repeatersToFlush.add(repeaterId); } } - + // Flush all repeaters that met the distance threshold for (final repeaterId in repeatersToFlush) { await _flushRepeater(repeaterId); } - + if (repeatersToFlush.isNotEmpty) { debugLog('[RX BATCH] Flushed ${repeatersToFlush.length} repeater(s) ' 'due to GPS movement'); @@ -331,20 +375,20 @@ class RxLogger { /// Flush a single repeater's batch - post best observation to API Future _flushRepeater(String repeaterId) async { debugLog('[RX BATCH] Flushing repeater $repeaterId'); - + final buffer = _batchBuffer[repeaterId]; if (buffer == null) { debugLog('[RX BATCH] No buffer to flush for repeater $repeaterId'); return; } - + // Clear timeout timer if it exists buffer.timeoutTimer?.cancel(); buffer.timeoutTimer = null; debugLog('[RX BATCH] Cleared timeout timer for repeater $repeaterId'); - + final best = buffer.bestObservation; - + // Build API entry using BEST observation's location final entry = RxApiEntry( repeaterId: repeaterId, @@ -356,14 +400,15 @@ class RxLogger { header: best.header, timestamp: best.timestamp, metadata: best.metadata, + displayHops: best.displayHops, ); - + debugLog('[RX BATCH] Posting repeater $repeaterId: snr=${best.snr}, ' 'location=${best.lat.toStringAsFixed(5)},${best.lon.toStringAsFixed(5)}'); - + // Queue for API posting await onRxEntry(entry); - + // Remove from buffer _batchBuffer.remove(repeaterId); debugLog('[RX BATCH] Repeater $repeaterId removed from buffer'); @@ -373,18 +418,18 @@ class RxLogger { Future flushAllBatches({String trigger = 'session_end'}) async { debugLog('[RX BATCH] Flushing all repeaters, trigger=$trigger, ' 'active_repeaters=${_batchBuffer.length}'); - + if (_batchBuffer.isEmpty) { debugLog('[RX BATCH] No repeaters to flush'); return; } - + // Iterate all repeaters and flush each one final repeaterIds = _batchBuffer.keys.toList(); for (final repeaterId in repeaterIds) { await _flushRepeater(repeaterId); } - + debugLog('[RX BATCH] All repeaters flushed: ${repeaterIds.length} total'); } @@ -397,18 +442,18 @@ class RxLogger { double lon2, ) { const earthRadiusM = 6371000.0; - + final dLat = _degreesToRadians(lat2 - lat1); final dLon = _degreesToRadians(lon2 - lon1); - + final a = sin(dLat / 2) * sin(dLat / 2) + cos(_degreesToRadians(lat1)) * cos(_degreesToRadians(lat2)) * sin(dLon / 2) * sin(dLon / 2); - + final c = 2 * atan2(sqrt(a), sqrt(1 - a)); - + return earthRadiusM * c; } @@ -427,12 +472,12 @@ class RxLogger { /// Dispose of resources void dispose() { debugLog('[RX LOG] Disposing RX Logger'); - + // Cancel all timeout timers for (final buffer in _batchBuffer.values) { buffer.timeoutTimer?.cancel(); } - + _batchBuffer.clear(); isWardriving = false; } @@ -454,8 +499,8 @@ class RxBatch { /// Single RX observation class RxObservation { final String repeaterId; // Hex ID of the repeater - final double? snr; // Null for CARpeater pass-through - final int? rssi; // Null for CARpeater pass-through + final double? snr; // Null for CARpeater pass-through + final int? rssi; // Null for CARpeater pass-through final int pathLength; final int header; final double lat; @@ -463,6 +508,10 @@ class RxObservation { final DateTime timestamp; final PacketMetadata metadata; + /// Display path hops, origin → ... → us. Already CARpeater-stripped (the + /// user's own carpeater, when present, has been removed from the tail). + final List displayHops; + RxObservation({ required this.repeaterId, this.snr, @@ -473,6 +522,7 @@ class RxObservation { required this.lon, required this.timestamp, required this.metadata, + this.displayHops = const [], }); } @@ -481,13 +531,16 @@ class RxApiEntry { final String repeaterId; final double lat; final double lon; - final double? snr; // Null for CARpeater pass-through - final int? rssi; // Null for CARpeater pass-through + final double? snr; // Null for CARpeater pass-through + final int? rssi; // Null for CARpeater pass-through final int pathLength; final int header; final DateTime timestamp; final PacketMetadata metadata; + /// Display path hops, origin → ... → us. Already CARpeater-stripped. + final List displayHops; + RxApiEntry({ required this.repeaterId, required this.lat, @@ -498,5 +551,6 @@ class RxApiEntry { required this.header, required this.timestamp, required this.metadata, + this.displayHops = const [], }); } diff --git a/lib/services/meshcore/trace_tracker.dart b/lib/services/meshcore/trace_tracker.dart new file mode 100644 index 00000000..6d2ddcdf --- /dev/null +++ b/lib/services/meshcore/trace_tracker.dart @@ -0,0 +1,225 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import '../../utils/debug_logger_io.dart'; + +/// Result of a trace path probe to a specific repeater +class TraceResult { + final String targetRepeaterId; + final double localSnr; // SNR we measured on the return (path_snrs[1] / 4.0) + final int localRssi; // RSSI from BLE event metadata + final double remoteSnr; // SNR the repeater measured (path_snrs[0] / 4.0) + final bool success; + + const TraceResult({ + required this.targetRepeaterId, + required this.localSnr, + required this.localRssi, + required this.remoteSnr, + required this.success, + }); +} + +/// Trace path tracker for targeted ping (zero-hop trace) +/// Sends CMD_SEND_TRACE_PATH (0x24) to a specific repeater and listens +/// for the trace response (PUSH_CODE_TRACE_DATA, 0x89). +/// +/// Follows the DiscTracker pattern but simpler: expects exactly 1 response. +class TraceTracker { + bool isListening = false; + Uint8List? _expectedTag; + String _targetRepeaterId = ''; + TraceResult? _result; + Timer? _windowTimer; + + /// BLE metadata from the 0x88 LogRxData event that arrives before the 0x89 TraceData. + /// Set by UnifiedRxHandler when it sees a trace packet in the LogRxData stream. + double pendingBleSnr = 0.0; + int pendingBleRssi = 0; + + /// Fired when a trace response is received during the window + void Function(TraceResult)? onTraceReceived; + + /// Fired when the listening window ends (result is null if no response) + void Function(TraceResult?)? onWindowComplete; + + TraceTracker(); + + /// Start tracking trace responses + void startTracking({ + required Uint8List tag, + required String targetRepeaterId, + Duration windowDuration = const Duration(seconds: 7), + }) { + debugLog('[TRACE] Starting trace tracking for repeater $targetRepeaterId'); + debugLog( + '[TRACE] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); + + isListening = true; + _expectedTag = tag; + _targetRepeaterId = targetRepeaterId; + _result = null; + pendingBleSnr = 0.0; + pendingBleRssi = 0; + + // Start window timer + _windowTimer?.cancel(); + _windowTimer = Timer(windowDuration, _endWindow); + + debugLog( + '[TRACE] Trace tracking window started (${windowDuration.inSeconds}s)'); + } + + /// Handle incoming trace data packet (0x89) + /// Returns true if the packet was a valid trace response + /// + /// Trace response format (per meshcore_py reference): + /// Byte 0: reserved (skip) + /// Byte 1: path_len (raw byte count of path hashes, NOT LogRxData encoding) + /// Byte 2: flags → hashSize = 1 << (flags & 3) + /// Bytes 3-6: tag → match against _expectedTag + /// Bytes 7-10: auth_code (skip) + /// Bytes 11 to 11+path_len: path_hashes → extract repeater ID + /// Next hopCount+1 bytes: path_snrs → each is signedInt8 / 4.0 for dB + /// For zero-hop (hopCount=1): remoteSnr = snrs[0], localSnr = snrs[1] + bool handlePacket(Uint8List rawBytes, double bleSnr, int bleRssi) { + if (!isListening) return false; + + try { + // Minimum: 1 (reserved) + 1 (path_len) + 1 (flags) + 4 (tag) + 4 (auth) = 11 bytes + if (rawBytes.length < 11) { + debugLog( + '[TRACE] Packet too short: ${rawBytes.length} bytes (need at least 11)'); + return false; + } + + // Skip byte 0 (reserved) + final pathLen = rawBytes[1]; // Raw byte count of path hashes + final flags = rawBytes[2]; + + // Decode per 0x89 trace format (meshcore_py reference): + // hash size from flags, hop count from path_len / hash_size + final hashSize = 1 << (flags & 3); // 1, 2, 4, or 8 bytes per hop + final hopCount = hashSize > 0 ? pathLen ~/ hashSize : 0; + + debugLog( + '[TRACE] pathLen=0x${pathLen.toRadixString(16)}, hashSize=$hashSize, hopCount=$hopCount'); + + // Extract tag (bytes 3-6) + final tag = rawBytes.sublist(3, 7); + + // Match tag against expected + final expectedTag = _expectedTag; + if (expectedTag != null) { + bool tagMatch = true; + for (int i = 0; i < 4; i++) { + if (tag[i] != expectedTag[i]) { + tagMatch = false; + break; + } + } + if (!tagMatch) { + debugLog('[TRACE] Tag mismatch, ignoring packet'); + return false; + } + } + + // Skip auth_code (bytes 7-10) + + // Extract path hashes (bytes 11 to 11 + hopCount*hashSize) + const pathStart = 11; + final pathEnd = pathStart + (hopCount * hashSize); + + if (rawBytes.length < pathEnd) { + debugLog( + '[TRACE] Packet too short for path hashes: need $pathEnd, have ${rawBytes.length}'); + return false; + } + + // Extract repeater ID from first hop in path + String repeaterId = ''; + if (hopCount > 0) { + final idBytes = rawBytes.sublist(pathStart, pathStart + hashSize); + repeaterId = idBytes + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join('') + .toUpperCase(); + } + + // Extract path SNRs (hopCount+1 bytes after path hashes) + final snrStart = pathEnd; + final snrEnd = snrStart + hopCount + 1; + + double remoteSnr = 0.0; + double localSnr = bleSnr; // Default to BLE metadata SNR + + if (rawBytes.length >= snrEnd && hopCount >= 1) { + // Each SNR byte is signed int8, divide by 4.0 for dB + remoteSnr = rawBytes[snrStart].toSigned(8) / 4.0; + if (hopCount + 1 >= 2) { + localSnr = rawBytes[snrStart + 1].toSigned(8) / 4.0; + } + } + + debugLog('[TRACE] Trace response from $repeaterId: ' + 'localSnr=${localSnr.toStringAsFixed(2)}, ' + 'remoteSnr=${remoteSnr.toStringAsFixed(2)}, ' + 'bleRssi=$bleRssi'); + + _result = TraceResult( + targetRepeaterId: _targetRepeaterId, + localSnr: localSnr, + localRssi: bleRssi, + remoteSnr: remoteSnr, + success: true, + ); + + // Notify callback + onTraceReceived?.call(_result!); + + return true; + } catch (e, stackTrace) { + debugError('[TRACE] Error processing trace response: $e'); + debugError('[TRACE] Stack trace: $stackTrace'); + return false; + } + } + + /// Stop tracking and return result + TraceResult? stopTracking() { + debugLog( + '[TRACE] Stopping trace tracking (result: ${_result != null ? 'received' : 'none'})'); + + final result = _result; + isListening = false; + _windowTimer?.cancel(); + _windowTimer = null; + _expectedTag = null; + + return result; + } + + /// Handle trace window completion + void _endWindow() { + debugLog( + '[TRACE] Trace window ended (result: ${_result != null ? 'success' : 'no response'})'); + + final result = _result; + isListening = false; + _windowTimer = null; + _expectedTag = null; + pendingBleSnr = 0.0; + pendingBleRssi = 0; + + onWindowComplete?.call(result); + } + + /// Dispose of resources — fires onWindowComplete if a window was active + void dispose() { + if (isListening) { + _endWindow(); + } else { + stopTracking(); + } + } +} diff --git a/lib/services/meshcore/tx_tracker.dart b/lib/services/meshcore/tx_tracker.dart index 21e63a45..7c6216f2 100644 --- a/lib/services/meshcore/tx_tracker.dart +++ b/lib/services/meshcore/tx_tracker.dart @@ -17,19 +17,27 @@ class TxTracker { int? expectedChannelHash; Uint8List? channelKey; - /// Map of repeaterId (hex) -> RepeaterEcho + /// Map of repeaterId (hex) -> RepeaterEcho (direct 1-hop echoes) final Map repeaters = {}; + /// Map of repeaterId (hex) -> RepeaterEcho (multi-hop 2+ hop echoes) + final Map multiHopRepeaters = {}; + Timer? _windowTimer; /// CARpeater prefix — when set, multi-hop packets with this firstHop are stripped /// to report the underlying repeater with null SNR/RSSI String? carpeaterPrefix; - /// Callback fired when a new echo is received (for real-time UI updates) + /// Callback fired when a direct echo is received (for real-time UI updates) /// Parameters: (repeaterId, snr, rssi, isNew) - isNew is true for first time seeing this repeater /// snr/rssi are nullable for CARpeater pass-through (signal data is meaningless) - void Function(String repeaterId, double? snr, int? rssi, bool isNew)? onEchoReceived; + void Function(String repeaterId, double? snr, int? rssi, bool isNew)? + onEchoReceived; + + /// Callback fired when a multi-hop echo is received (for real-time UI updates) + void Function(String repeaterId, double? snr, int? rssi, + List pathHops, bool isNew)? onMultiHopEchoReceived; /// Callback for carpeater drops (for quiet error logging) /// Called with repeater ID and reason when an echo is dropped due to carpeater detection @@ -43,7 +51,7 @@ class TxTracker { bool disableRssiFilter = false; /// Start tracking echoes for a sent ping - /// + /// /// @param payload - The message text sent (for content verification) /// @param channelIdx - Channel index where ping was sent /// @param channelHash - Expected channel hash for validation @@ -58,8 +66,9 @@ class TxTracker { }) { debugLog('[TX LOG] Starting echo tracking'); debugLog('[TX LOG] Payload: "$payload"'); - debugLog('[TX LOG] Channel: $channelIdx, Hash: 0x${channelHash.toRadixString(16).padLeft(2, '0')}'); - + debugLog( + '[TX LOG] Channel: $channelIdx, Hash: 0x${channelHash.toRadixString(16).padLeft(2, '0')}'); + isListening = true; sentTimestamp = DateTime.now(); sentPayload = payload; @@ -67,54 +76,68 @@ class TxTracker { expectedChannelHash = channelHash; this.channelKey = channelKey; repeaters.clear(); - + multiHopRepeaters.clear(); + // Start window timer _windowTimer?.cancel(); _windowTimer = Timer(windowDuration, stopTracking); - - debugLog('[TX LOG] Echo tracking window started (${windowDuration.inSeconds}s)'); + + debugLog( + '[TX LOG] Echo tracking window started (${windowDuration.inSeconds}s)'); } /// Stop tracking echoes void stopTracking() { - debugLog('[TX LOG] Stopping echo tracking (heard ${repeaters.length} repeaters)'); - + debugLog( + '[TX LOG] Stopping echo tracking (heard ${repeaters.length} direct, ' + '${multiHopRepeaters.length} multi-hop repeaters)'); + isListening = false; _windowTimer?.cancel(); _windowTimer = null; - + // Log final results if (repeaters.isNotEmpty) { for (final entry in repeaters.entries) { - debugLog('[TX LOG] Final: ${entry.key} -> SNR=${entry.value.snr ?? 'null'}, seen=${entry.value.seenCount}x'); + debugLog( + '[TX LOG] Final direct: ${entry.key} -> SNR=${entry.value.snr ?? 'null'}, seen=${entry.value.seenCount}x'); + } + } + if (multiHopRepeaters.isNotEmpty) { + for (final entry in multiHopRepeaters.entries) { + debugLog( + '[TX LOG] Final multi-hop: ${entry.key} -> SNR=${entry.value.snr ?? 'null'}, ' + 'hops=${entry.value.pathHops.length}, seen=${entry.value.seenCount}x'); } } } /// Handle incoming packet, check if it's an echo - /// Returns true if packet was an echo and tracked - Future handlePacket(PacketMetadata metadata) async { - if (!isListening) return false; - + /// Returns TxEchoResult indicating whether packet was consumed and how + Future handlePacket(PacketMetadata metadata) async { + if (!isListening) return TxEchoResult.notEcho; + final originalPayload = sentPayload; final expectedHash = expectedChannelHash; - + try { - debugLog('[TX LOG] Processing rx_log entry: SNR=${metadata.snr}, RSSI=${metadata.rssi}'); + debugLog( + '[TX LOG] Processing rx_log entry: SNR=${metadata.snr}, RSSI=${metadata.rssi}'); // VALIDATION STEP 1: Header validation (must be GROUP_TEXT) if (!metadata.isGroupText) { debugLog('[TX LOG] Ignoring: header validation failed ' '(header=0x${metadata.header.toRadixString(16).padLeft(2, '0')})'); - return false; + return TxEchoResult.notEcho; } - debugLog('[TX LOG] Header validation passed: 0x${metadata.header.toRadixString(16).padLeft(2, '0')}'); + debugLog( + '[TX LOG] Header validation passed: 0x${metadata.header.toRadixString(16).padLeft(2, '0')}'); // VALIDATION STEP 1.5: Path length check (must have hops to identify repeater) - // Moved before RSSI check so we can log the repeater ID on carpeater drops if (metadata.pathHashCount == 0) { - debugLog('[TX LOG] Ignoring: no path (direct transmission, not a repeater echo)'); - return false; + debugLog( + '[TX LOG] Ignoring: no path (direct transmission, not a repeater echo)'); + return TxEchoResult.notEcho; } // Extract first hop (first repeater) for use in validation and logging @@ -125,47 +148,72 @@ class TxTracker { double? reportedSnr = metadata.snr; int? reportedRssi = metadata.rssi; - if (carpeaterPrefix != null && pathHex.toUpperCase() == carpeaterPrefix!.toUpperCase()) { + if (carpeaterPrefix != null && + PacketValidator.isCarpeaterIdMatch(pathHex, carpeaterPrefix!)) { if (metadata.pathHashCount < 2) { debugLog('[TX LOG] CARpeater pass-through: single-hop, dropping'); - return false; + return TxEchoResult.notEcho; } // Multi-hop: strip CARpeater, report underlying repeater (second hop) final underlyingHex = metadata.getHopHex(1)!; - debugLog('[TX LOG] CARpeater pass-through: stripped $pathHex, reporting underlying repeater $underlyingHex'); + debugLog( + '[TX LOG] CARpeater pass-through: stripped $pathHex, reporting underlying repeater $underlyingHex'); pathHex = underlyingHex; carpeaterStripped = true; reportedSnr = null; reportedRssi = null; } - // VALIDATION STEP 2: Check user carpeater filter (before RSSI check so user - // never sees confusing "RSSI too strong" errors for a device they told the app to ignore) - if (!carpeaterStripped && shouldIgnoreRepeater != null && shouldIgnoreRepeater!(pathHex.toUpperCase())) { - debugLog('[TX LOG] ❌ DROPPED: Repeater $pathHex ignored by user carpeater filter'); - return false; + // Determine if this is a multi-hop echo (2+ hops, not CARpeater-stripped) + final bool isMultiHop = !carpeaterStripped && metadata.pathHashCount > 1; + + // For multi-hop: use lastHop as reporting repeater, build display path + List displayHops = const []; + if (isMultiHop) { + pathHex = metadata.lastHopHex!; + displayHops = [ + for (var i = 0; i < metadata.pathHashCount; i++) + metadata.getHopHex(i)!, + ]; + debugLog( + '[TX LOG] Multi-hop echo (pathHashCount=${metadata.pathHashCount}): ' + 'reporting repeater=$pathHex, path=${displayHops.join(' → ')}'); + } + + // VALIDATION STEP 2: Check user carpeater filter + if (!carpeaterStripped && + shouldIgnoreRepeater != null && + shouldIgnoreRepeater!(pathHex.toUpperCase())) { + debugLog( + '[TX LOG] ❌ DROPPED: Repeater $pathHex ignored by user carpeater filter'); + return TxEchoResult.notEcho; } // VALIDATION STEP 2.5: Check RSSI (carpeater failsafe) - // Skip for CARpeater pass-through (user explicitly identified their CARpeater) if (carpeaterStripped) { debugLog('[TX LOG] RSSI check skipped (CARpeater pass-through)'); } else if (disableRssiFilter) { - debugLog('[TX LOG] RSSI filter disabled by user, skipping carpeater check'); + debugLog( + '[TX LOG] RSSI filter disabled by user, skipping carpeater check'); } else if (PacketValidator.isCarpeater(metadata.rssi)) { - debugLog('[TX LOG] ❌ DROPPED: RSSI too strong (${metadata.rssi} ≥ ${PacketValidator.maxRssiThreshold}) ' + debugLog( + '[TX LOG] ❌ DROPPED: RSSI too strong (${metadata.rssi} ≥ ${PacketValidator.maxRssiThreshold}) ' '- possible carpeater (RSSI failsafe), repeater=$pathHex'); - debugLog('[TX LOG] onCarpeaterDrop callback is ${onCarpeaterDrop != null ? "SET" : "NULL"}'); - onCarpeaterDrop?.call(pathHex, 'RSSI too strong (${metadata.rssi} dBm)'); - return false; // Mark as handled (dropped) + debugLog( + '[TX LOG] onCarpeaterDrop callback is ${onCarpeaterDrop != null ? "SET" : "NULL"}'); + onCarpeaterDrop?.call( + pathHex, 'RSSI too strong (${metadata.rssi} dBm)'); + return TxEchoResult.notEcho; } else { - debugLog('[TX LOG] ✓ RSSI OK (${metadata.rssi} < ${PacketValidator.maxRssiThreshold})'); + debugLog( + '[TX LOG] ✓ RSSI OK (${metadata.rssi} < ${PacketValidator.maxRssiThreshold})'); } // VALIDATION STEP 3: Channel hash validation if (metadata.encryptedPayload.length < 3) { - debugLog('[TX LOG] Ignoring: payload too short to contain channel hash'); - return false; + debugLog( + '[TX LOG] Ignoring: payload too short to contain channel hash'); + return TxEchoResult.notEcho; } final packetChannelHash = metadata.channelHash!; @@ -175,123 +223,145 @@ class TxTracker { if (packetChannelHash != expectedHash) { debugLog('[TX LOG] Ignoring: channel hash mismatch'); - return false; + return TxEchoResult.notEcho; } - debugLog('[TX LOG] Channel hash match confirmed - this is a message on our channel'); + debugLog( + '[TX LOG] Channel hash match confirmed - this is a message on our channel'); - // VALIDATION STEP 3: Message content verification + // VALIDATION STEP 4: Message content verification if (channelKey != null && originalPayload != null) { - debugLog('[MESSAGE_CORRELATION] Channel key available, attempting decryption...'); + debugLog( + '[MESSAGE_CORRELATION] Channel key available, attempting decryption...'); try { - // Payload structure: [1 byte channel_hash][2 bytes MAC][encrypted message] - // Skip first 3 bytes to get the encrypted message final encryptedMessage = metadata.encryptedPayload.sublist(3); final decryptedBytes = CryptoService.decryptChannelMessage( encryptedMessage, channelKey!, ); - // Decrypted structure: [4 bytes timestamp][1 byte flags][message text] - // Skip first 5 bytes to get the actual message if (decryptedBytes.length < 5) { - debugLog('[MESSAGE_CORRELATION] ❌ REJECT: Decrypted data too short'); - return false; + debugLog( + '[MESSAGE_CORRELATION] ❌ REJECT: Decrypted data too short'); + return TxEchoResult.notEcho; } final messageBytes = decryptedBytes.sublist(5); - // Convert bytes to string and strip null terminators - var decryptedMessage = utf8.decode(messageBytes, allowMalformed: true); - decryptedMessage = decryptedMessage.replaceAll(RegExp(r'\x00+$'), '').trim(); + var decryptedMessage = + utf8.decode(messageBytes, allowMalformed: true); + decryptedMessage = + decryptedMessage.replaceAll(RegExp(r'\x00+$'), '').trim(); - debugLog('[MESSAGE_CORRELATION] Decryption successful, comparing content...'); - debugLog('[MESSAGE_CORRELATION] Decrypted: "$decryptedMessage" (${decryptedMessage.length} chars)'); - debugLog('[MESSAGE_CORRELATION] Expected: "$originalPayload" (${originalPayload.length} chars)'); + debugLog( + '[MESSAGE_CORRELATION] Decryption successful, comparing content...'); + debugLog( + '[MESSAGE_CORRELATION] Decrypted: "$decryptedMessage" (${decryptedMessage.length} chars)'); + debugLog( + '[MESSAGE_CORRELATION] Expected: "$originalPayload" (${originalPayload.length} chars)'); - // Check if our expected message is contained in the decrypted text - // This handles both exact matches and messages with sender prefixes final messageMatches = decryptedMessage == originalPayload || decryptedMessage.contains(originalPayload); if (!messageMatches) { - debugLog('[MESSAGE_CORRELATION] ❌ REJECT: Message content mismatch (not an echo of our ping)'); - debugLog('[MESSAGE_CORRELATION] This is a different message on the same channel'); - return false; + debugLog( + '[MESSAGE_CORRELATION] ❌ REJECT: Message content mismatch (not an echo of our ping)'); + debugLog( + '[MESSAGE_CORRELATION] This is a different message on the same channel'); + return TxEchoResult.notEcho; } if (decryptedMessage == originalPayload) { - debugLog('[MESSAGE_CORRELATION] ✅ Exact message match confirmed - this is an echo of our ping!'); + debugLog( + '[MESSAGE_CORRELATION] ✅ Exact message match confirmed - this is an echo of our ping!'); } else { - debugLog('[MESSAGE_CORRELATION] ✅ Message contained in decrypted text (with sender prefix) ' + debugLog( + '[MESSAGE_CORRELATION] ✅ Message contained in decrypted text (with sender prefix) ' '- this is an echo of our ping!'); } } catch (e) { - debugLog('[MESSAGE_CORRELATION] ❌ REJECT: Failed to decrypt message: $e'); - return false; + debugLog( + '[MESSAGE_CORRELATION] ❌ REJECT: Failed to decrypt message: $e'); + return TxEchoResult.notEcho; } } else { - debugWarn('[MESSAGE_CORRELATION] ⚠️ WARNING: Cannot verify message content - channel key not available'); - debugWarn('[MESSAGE_CORRELATION] Proceeding without message content verification (less reliable)'); + debugWarn( + '[MESSAGE_CORRELATION] ⚠️ WARNING: Cannot verify message content - channel key not available'); + debugWarn( + '[MESSAGE_CORRELATION] Proceeding without message content verification (less reliable)'); } - // Path length and first hop already validated/extracted earlier (before RSSI check) + // --- Validation passed, store the echo --- - debugLog('[PING] Repeater echo accepted: first_hop=$pathHex, SNR=$reportedSnr, ' + final targetMap = isMultiHop ? multiHopRepeaters : repeaters; + final echoType = isMultiHop ? 'multi-hop' : 'direct'; + + debugLog( + '[PING] Repeater echo accepted ($echoType): repeater=$pathHex, SNR=$reportedSnr, ' 'full_path_length=${metadata.pathHashCount}${carpeaterStripped ? ' (CARpeater stripped)' : ''}'); // Deduplication: check if we already have this repeater bool isNewRepeater = false; - if (repeaters.containsKey(pathHex)) { - final existing = repeaters[pathHex]!; - debugLog('[PING] Deduplication: path $pathHex already seen ' + if (targetMap.containsKey(pathHex)) { + final existing = targetMap[pathHex]!; + debugLog('[PING] Deduplication ($echoType): path $pathHex already seen ' '(existing SNR=${existing.snr}, new SNR=$reportedSnr)'); - // Keep the best (highest) SNR — null SNR never replaces non-null final shouldUpdate = reportedSnr != null && existing.snr != null ? reportedSnr > existing.snr! : reportedSnr != null && existing.snr == null; if (shouldUpdate) { - debugLog('[PING] Deduplication decision: updating path $pathHex with better SNR: ' + debugLog( + '[PING] Deduplication decision: updating path $pathHex with better SNR: ' '${existing.snr} -> $reportedSnr'); - repeaters[pathHex] = RepeaterEcho( + targetMap[pathHex] = RepeaterEcho( repeaterId: pathHex, snr: reportedSnr, rssi: reportedRssi, seenCount: existing.seenCount + 1, + isMultiHop: isMultiHop, + pathHops: displayHops, ); } else { - debugLog('[PING] Deduplication decision: keeping existing SNR for path $pathHex ' + debugLog( + '[PING] Deduplication decision: keeping existing SNR for path $pathHex ' '(existing ${existing.snr} >= new $reportedSnr)'); - // Still increment seen count existing.seenCount++; } } else { - // New repeater isNewRepeater = true; - debugLog('[PING] Adding new repeater echo: path=$pathHex, SNR=$reportedSnr, RSSI=$reportedRssi'); - repeaters[pathHex] = RepeaterEcho( + debugLog( + '[PING] Adding new $echoType echo: path=$pathHex, SNR=$reportedSnr, RSSI=$reportedRssi'); + targetMap[pathHex] = RepeaterEcho( repeaterId: pathHex, snr: reportedSnr, rssi: reportedRssi, seenCount: 1, + isMultiHop: isMultiHop, + pathHops: displayHops, ); } - // Notify callback for real-time UI updates - final bestSnr = repeaters[pathHex]!.snr; - final bestRssi = repeaters[pathHex]!.rssi; - debugLog('[TX LOG] Invoking onEchoReceived callback (callback=${onEchoReceived != null ? "SET" : "NULL"})'); - if (onEchoReceived != null) { - onEchoReceived!(pathHex, bestSnr, bestRssi, isNewRepeater); - debugLog('[TX LOG] onEchoReceived callback invoked successfully'); + // Notify appropriate callback + final best = targetMap[pathHex]!; + if (isMultiHop) { + debugLog('[TX LOG] Invoking onMultiHopEchoReceived callback'); + onMultiHopEchoReceived?.call( + pathHex, best.snr, best.rssi, displayHops, isNewRepeater); + } else { + debugLog( + '[TX LOG] Invoking onEchoReceived callback (callback=${onEchoReceived != null ? "SET" : "NULL"})'); + if (onEchoReceived != null) { + onEchoReceived!(pathHex, best.snr, best.rssi, isNewRepeater); + debugLog('[TX LOG] onEchoReceived callback invoked successfully'); + } } - debugLog('[TX LOG] ✅ Echo tracked successfully'); - return true; + debugLog('[TX LOG] ✅ Echo tracked successfully ($echoType)'); + return isMultiHop ? TxEchoResult.multiHopEcho : TxEchoResult.directEcho; } catch (error, stackTrace) { debugError('[TX LOG] Error processing rx_log entry: $error'); debugError('[TX LOG] Stack trace: $stackTrace'); - return false; + return TxEchoResult.notEcho; } } @@ -301,17 +371,33 @@ class TxTracker { } } +/// Result of TxTracker.handlePacket() +enum TxEchoResultType { notEcho, directEcho, multiHopEcho } + +class TxEchoResult { + final TxEchoResultType type; + + const TxEchoResult._(this.type); + static const notEcho = TxEchoResult._(TxEchoResultType.notEcho); + static const directEcho = TxEchoResult._(TxEchoResultType.directEcho); + static const multiHopEcho = TxEchoResult._(TxEchoResultType.multiHopEcho); +} + /// Repeater echo data class RepeaterEcho { - final String repeaterId; // Hex string - double? snr; // Best SNR seen (null for CARpeater pass-through) - int? rssi; // RSSI value (dBm) (null for CARpeater pass-through) - int seenCount; // Times observed + final String repeaterId; // Hex string + double? snr; // Best SNR seen (null for CARpeater pass-through) + int? rssi; // RSSI value (dBm) (null for CARpeater pass-through) + int seenCount; // Times observed + final bool isMultiHop; + final List pathHops; RepeaterEcho({ required this.repeaterId, this.snr, this.rssi, this.seenCount = 1, + this.isMultiHop = false, + this.pathHops = const [], }); } diff --git a/lib/services/meshcore/unified_rx_handler.dart b/lib/services/meshcore/unified_rx_handler.dart index 6e92b74d..b5946d21 100644 --- a/lib/services/meshcore/unified_rx_handler.dart +++ b/lib/services/meshcore/unified_rx_handler.dart @@ -4,6 +4,7 @@ import '../../utils/debug_logger_io.dart'; import 'packet_metadata.dart'; import 'packet_validator.dart'; import 'rx_logger.dart'; +import 'trace_tracker.dart'; import 'tx_tracker.dart'; /// Unified RX handler - orchestrates TX echo tracking and passive RX logging @@ -18,6 +19,9 @@ class UnifiedRxHandler { /// Get current validator PacketValidator get validator => _validator; + /// Trace tracker for targeted ping mode (set by PingService when active) + TraceTracker? traceTracker; + /// Channel key for message decryption (injected for TX validation) Uint8List? channelKey; @@ -37,7 +41,7 @@ class UnifiedRxHandler { /// Start unified RX listening void startListening() { if (isListening) return; - + debugLog('[UNIFIED RX] Starting unified RX listening'); isListening = true; debugLog('[UNIFIED RX] ✅ Unified listening started successfully'); @@ -46,7 +50,7 @@ class UnifiedRxHandler { /// Stop unified RX listening void stopListening() { if (!isListening) return; - + debugLog('[UNIFIED RX] Stopping unified RX listening'); isListening = false; debugLog('[UNIFIED RX] ✅ Unified listening stopped'); @@ -58,40 +62,59 @@ class UnifiedRxHandler { try { // Defensive check: ensure listener is marked as active if (!isListening) { - debugWarn('[UNIFIED RX] Received event but listener marked inactive - reactivating'); + debugWarn( + '[UNIFIED RX] Received event but listener marked inactive - reactivating'); isListening = true; } - + // Parse metadata ONCE final metadata = PacketMetadata.fromRawPacket( raw: rawPacket, snr: snr, rssi: rssi, ); - + debugLog('[UNIFIED RX] Packet received: ' 'header=0x${metadata.header.toRadixString(16)}, ' 'pathHashSize=${metadata.pathHashSize}, pathHashCount=${metadata.pathHashCount}'); - + + // Store BLE metadata from 0x88 LogRxData for trace packets. + // The actual trace payload arrives separately via 0x89 TraceData stream. + // We only store RSSI/SNR here — the 0x89 handler combines them. + if (metadata.isTrace) { + final tt = traceTracker; + if (tt != null && tt.isListening) { + debugLog( + '[UNIFIED RX] Trace packet in 0x88 - storing BLE metadata for 0x89 handler'); + tt.pendingBleSnr = metadata.snr; + tt.pendingBleRssi = metadata.rssi; + } + return; // Trace packets don't go to TX tracker or RX logger + } + // Route to TX tracking if active (during 5s echo window) if (txTracker.isListening) { debugLog('[UNIFIED RX] TX tracking active - checking for echo'); - final wasEcho = await txTracker.handlePacket(metadata); - if (wasEcho) { - debugLog('[UNIFIED RX] Packet was TX echo, done'); + final result = await txTracker.handlePacket(metadata); + if (result.type == TxEchoResultType.directEcho) { + debugLog('[UNIFIED RX] Packet was direct TX echo, done'); + return; + } + if (result.type == TxEchoResultType.multiHopEcho) { + debugLog( + '[UNIFIED RX] Packet was multi-hop TX echo, grouped with TX'); return; } } - + // Route to RX wardriving if active if (rxLogger.isWardriving) { debugLog('[UNIFIED RX] RX wardriving active - logging observation'); await rxLogger.handlePacket(metadata, validator); } - + // If neither active, packet is received but ignored // Listener stays on, just not processing for wardriving - } catch (error, stackTrace) { debugError('[UNIFIED RX] Error processing rx_log entry: $error'); debugError('[UNIFIED RX] Stack trace: $stackTrace'); diff --git a/lib/services/meshcore/wire_tag_codec.dart b/lib/services/meshcore/wire_tag_codec.dart new file mode 100644 index 00000000..f4cfd8aa --- /dev/null +++ b/lib/services/meshcore/wire_tag_codec.dart @@ -0,0 +1,179 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +/// Encodes/decodes the on-air TX "wire tag" that replaces plaintext GPS +/// coordinates on the `#wardriving` channel. +/// +/// A tag is `"MM:" + 10 base64url chars` (13 chars total). It packs the origin +/// region, the **session date**, the session number, and the per-session ping +/// counter into 55 bits, then runs a keyed 4-round Feistel so it can only be +/// decoded with the shared secret. +/// +/// Layout (MSB→LSB): `region(15) | date(15) | NNNN(14) | counter(11)` where +/// `date = (year-2020)*512 + month*32 + day`. The date IS encoded (unlike the +/// original 40-bit tag) so a recycled daily NNNN mints a globally-unique tag — +/// this is the "green TX → DEAD tile" fix. `decode` fully reconstructs the +/// session_id `(region)-(YYYYMMDD)-(NNNN)`; no external date context is needed. +/// +/// This MUST stay byte-identical to the PHP `wireTagEncode`/`wireTagDecode` +/// twins — see `test/services/wire_tag_codec_test.dart` for the canonical +/// cross-language vectors. +/// +/// Web safety: this app runs on web (dart2js), where the bitwise operators +/// `<<`, `>>`, `&`, `|` truncate to 32 bits. The packed value is up to 55 bits +/// (> 2^53, the exact-integer ceiling for JS doubles), so the codec NEVER +/// materializes the full value — it works on two ≤28-bit halves and keeps every +/// intermediate below 2^40, using plain arithmetic (`*`, `~/`, `%`) which is +/// exact below 2^53. Bitwise ops appear only on the two ≤28-bit Feistel halves +/// (always < 2^31). +class WireTagCodec { + WireTagCodec._(); + + /// Marker prefix that distinguishes a MeshMapper tag from ordinary chatter. + static const String prefix = 'MM:'; + + static const int _pow11 = 2048; // 2^11 (counter field) + static const int _pow25 = 33554432; // 2^25 (date field shift) + static const int _pow28 = 268435456; // 2^28 (Feistel half size) + static const int _pow12 = 4096; // 2^12 (region within the high half) + static const int _mask28 = 0xFFFFFFF; // 28 bits — safe (< 2^31) + + // Date sub-fields: date = (year - _yearBase)*_pow9 + month*_pow5 + day. + static const int _yearBase = 2020; + static const int _pow9 = 512; // year shift (6-bit year → 2020..2083) + static const int _pow5 = 32; // month shift (4-bit month / 5-bit day) + + static int _regionPack(String iata) { + final u = iata.toUpperCase(); + return (u.codeUnitAt(0) - 65) * 676 + + (u.codeUnitAt(1) - 65) * 26 + + (u.codeUnitAt(2) - 65); + } + + static String _regionUnpack(int n) => + String.fromCharCodes([n ~/ 676 + 65, (n % 676) ~/ 26 + 65, n % 26 + 65]); + + /// Feistel round function: first 4 bytes of SHA-256(secret ‖ round ‖ half), + /// masked to 28 bits. `half` (< 2^28) is serialized big-endian as 4 bytes. + static int _f(List secret, int half, int round) { + final input = [ + ...secret, + round, + (half ~/ 16777216) % 256, // 2^24 + (half ~/ 65536) % 256, // 2^16 + (half ~/ 256) % 256, + half % 256, + ]; + final d = sha256.convert(input).bytes; + return (d[0] * 16777216 + d[1] * 65536 + d[2] * 256 + d[3]) % _pow28; + } + + /// 4-round balanced Feistel on the two 28-bit halves `(hi, lo)`. + /// Returns the transformed `(left, right)` pair — the full value is never + /// formed (it would exceed 2^53 on web). + static (int, int) _feistel(List secret, int hi, int lo, + {required bool decrypt}) { + var l = hi; + var r = lo; + for (final round in decrypt ? const [3, 2, 1, 0] : const [0, 1, 2, 3]) { + if (!decrypt) { + final newL = r; + r = (l ^ _f(secret, r, round)) & _mask28; + l = newL; + } else { + final newR = l; + l = (r ^ _f(secret, l, round)) & _mask28; + r = newR; + } + } + return (l, r); + } + + /// Serialize the 56-bit value `hi*2^28 + lo` (each half < 2^28) as 7 bytes, + /// big-endian — without ever forming the full integer. + static Uint8List _toBytes7(int hi, int lo) { + return Uint8List.fromList([ + (hi ~/ 1048576) % 256, // 2^20 → bits 48-55 + (hi ~/ 4096) % 256, // 2^12 → bits 40-47 + (hi ~/ 16) % 256, // 2^4 → bits 32-39 + (hi % 16) * 16 + (lo ~/ 16777216) % 16, // bits 24-31 (straddle) + (lo ~/ 65536) % 256, // bits 16-23 + (lo ~/ 256) % 256, // bits 8-15 + lo % 256, // bits 0-7 + ]); + } + + /// Inverse of [_toBytes7]: 7 bytes → `(hi, lo)`, each half < 2^28. + static (int, int) _fromBytes7(List b) { + final lo = b[6] + b[5] * 256 + b[4] * 65536 + (b[3] % 16) * 16777216; + final hi = (b[3] ~/ 16) + b[2] * 16 + b[1] * 4096 + b[0] * 1048576; + return (hi, lo); + } + + static String _b64url(List bytes) => + base64Url.encode(bytes).replaceAll('=', ''); + + static Uint8List _unb64url(String s) => + base64Url.decode(s + ('=' * ((4 - s.length % 4) % 4))); + + /// Encode `sessionId` (`IATA-YYYYMMDD-NNNN`) + `counter` into the wire body. + /// `key` is the shared secret from `/auth`; null/empty uses the un-keyed + /// fallback (still coord-free, just not key-protected). + static String encode(String sessionId, int counter, String? key) { + final parts = sessionId.split('-'); + // Defensive guard: offline-upload ids ("offline-YYYYMMDD-NNNN") are passive-only and must + // never be wire-tag encoded — parts[0] would be the literal "offline", not a region code. + // The offline flow never calls encode() (only the live TX path does); this protects against + // an accidental future caller and a malformed id (which would otherwise RangeError on parts[2]). + if (parts.length < 3 || parts[0].toLowerCase() == 'offline') { + throw ArgumentError( + 'Cannot wire-tag encode an offline / non-region session id: $sessionId'); + } + final ymd = parts[1]; + final year = int.parse(ymd.substring(0, 4)); + final month = int.parse(ymd.substring(4, 6)); + final day = int.parse(ymd.substring(6, 8)); + final date = (year - _yearBase) * _pow9 + month * _pow5 + day; + + // Pack the low 40 bits, then split at bit 28 into the two Feistel halves. + // lowPart < 2^40, so it (and the carry) stay well below 2^53 — web-safe. + final lowPart = date * _pow25 + int.parse(parts[2]) * _pow11 + counter; + final lo = lowPart % _pow28; + final hi = _regionPack(parts[0]) * _pow12 + lowPart ~/ _pow28; + + final (cl, cr) = _feistel(utf8.encode(key ?? ''), hi, lo, decrypt: false); + return prefix + _b64url(_toBytes7(cl, cr)); + } + + /// Decode a wire body back to region / date / session# / counter using the + /// key alone (no database needed). The session_id is fully recoverable as + /// `region-YYYYMMDD-NNNN`. + static ({ + String region, + int year, + int month, + int day, + int sessionNum, + int counter + }) decode(String body, String? key) { + final token = + body.startsWith(prefix) ? body.substring(prefix.length) : body; + final (chi, clo) = _fromBytes7(_unb64url(token)); + final (hi, lo) = _feistel(utf8.encode(key ?? ''), chi, clo, decrypt: true); + + final region = hi ~/ _pow12; + final lowPart = (hi % _pow12) * _pow28 + lo; + final date = lowPart ~/ _pow25; + final rem = lowPart % _pow25; + return ( + region: _regionUnpack(region), + year: date ~/ _pow9 + _yearBase, + month: (date % _pow9) ~/ _pow5, + day: date % _pow5, + sessionNum: rem ~/ _pow11, + counter: rem % _pow11, + ); + } +} diff --git a/lib/services/offline_map_service.dart b/lib/services/offline_map_service.dart new file mode 100644 index 00000000..de685d10 --- /dev/null +++ b/lib/services/offline_map_service.dart @@ -0,0 +1,916 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:disk_space_plus/disk_space_plus.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../utils/debug_logger_io.dart'; +import 'tile_cache_service.dart'; + +/// A pending download waiting in the FIFO queue. +class _QueuedDownload { + final String name; + final LatLngBounds bounds; + final String styleUrl; + final String styleName; + final double minZoom; + final double maxZoom; + final int estBytes; + final Completer completer; + + _QueuedDownload({ + required this.name, + required this.bounds, + required this.styleUrl, + required this.styleName, + required this.minZoom, + required this.maxZoom, + required this.estBytes, + required this.completer, + }); +} + +/// Metadata keys stored with each offline region. +class _MetaKeys { + _MetaKeys._(); + static const name = 'name'; + static const styleName = 'styleName'; + static const createdAt = 'createdAt'; + + /// Estimated size in bytes (rough heuristic based on tile count). + static const estimatedBytes = 'estimatedBytes'; +} + +/// A user-friendly wrapper around a raw [OfflineRegion]. +class OfflineMapRegion { + final int id; + final String name; + final String styleName; + final LatLngBounds bounds; + final double minZoom; + final double maxZoom; + final DateTime createdAt; + + /// Heuristic size from tile count × 20 KB, captured at download time. + /// Used as a fallback when the native SDK hasn't reported actual bytes yet. + final int estimatedBytes; + + /// Real bytes consumed by this region as reported by the native MapLibre + /// SDK (`MLNOfflinePack.progress.countOfTileBytesCompleted` on iOS, + /// `OfflineRegionStatus.completedResourceSize` on Android). `null` when + /// the native map hasn't reported a size for this region yet. + final int? actualBytes; + + const OfflineMapRegion({ + required this.id, + required this.name, + required this.styleName, + required this.bounds, + required this.minZoom, + required this.maxZoom, + required this.createdAt, + required this.estimatedBytes, + this.actualBytes, + }); + + factory OfflineMapRegion.fromOfflineRegion( + OfflineRegion region, { + int? actualBytes, + }) { + final meta = region.metadata; + return OfflineMapRegion( + id: region.id, + name: (meta[_MetaKeys.name] as String?) ?? 'Region ${region.id}', + styleName: (meta[_MetaKeys.styleName] as String?) ?? 'Unknown', + bounds: region.definition.bounds, + minZoom: region.definition.minZoom, + maxZoom: region.definition.maxZoom, + createdAt: + DateTime.tryParse((meta[_MetaKeys.createdAt] as String?) ?? '') ?? + DateTime.now(), + // Platform channel JSON round-trip can return int as num, double, or + // (on some Android paths) a stringified form. Tolerate all three. + estimatedBytes: switch (meta[_MetaKeys.estimatedBytes]) { + final num n => n.toInt(), + final String s => int.tryParse(s) ?? 0, + _ => 0, + }, + actualBytes: actualBytes, + ); + } + + /// Size to show in the UI — real bytes when the native SDK has reported + /// them, falling back to the download-time estimate otherwise. + int get sizeBytes => actualBytes ?? estimatedBytes; + + /// Human-readable size string. + String get sizeDisplay { + final b = sizeBytes; + if (b < 1024) return '$b B'; + if (b < 1024 * 1024) return '${(b / 1024).toStringAsFixed(1)} KB'; + return '${(b / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + + /// Short bounds description (e.g. "49.2°N, 123.1°W"). + String get boundsDisplay { + final sw = bounds.southwest; + final ne = bounds.northeast; + final latCenter = (sw.latitude + ne.latitude) / 2; + final lngCenter = (sw.longitude + ne.longitude) / 2; + final latDir = latCenter >= 0 ? 'N' : 'S'; + final lngDir = lngCenter >= 0 ? 'E' : 'W'; + return '${latCenter.abs().toStringAsFixed(2)}°$latDir, ' + '${lngCenter.abs().toStringAsFixed(2)}°$lngDir'; + } +} + +/// Manages offline map tile downloads, listing, deletion, and storage limits. +/// +/// Lives at the app level (provided via [ChangeNotifierProvider] in main.dart) +/// so downloads continue when the user navigates away from the Offline Maps +/// screen. A system notification shows real-time progress. +/// +/// Not available on web (maplibre_gl offline APIs are mobile-only). +class OfflineMapService extends ChangeNotifier { + static const _storageLimitKey = 'offline_map_storage_limit_mb'; + static const int defaultStorageLimitMb = 500; + static const int minStorageLimitMb = 50; + static const int maxStorageLimitMb = 5000; + + // ── Notification constants ── + static const String _notifChannelId = 'meshmapper_offline_maps'; + static const String _notifChannelName = 'Offline Map Downloads'; + static const int _progressNotifId = 889; + static const int _completeNotifId = 890; + + final FlutterLocalNotificationsPlugin _notifPlugin = + FlutterLocalNotificationsPlugin(); + bool _notifInitialized = false; + + // ── Region state ── + List _regions = []; + List get regions => List.unmodifiable(_regions); + + int _storageLimitMb = defaultStorageLimitMb; + int get storageLimitMb => _storageLimitMb; + int get storageLimitBytes => _storageLimitMb * 1024 * 1024; + + /// Actual on-disk cache.db size as reported by the native platform + /// (MapLibre stores downloaded regions and ambient tiles in one SQLite + /// file). Refreshed alongside [refreshRegions]. + int _totalCacheBytes = 0; + + /// Actual bytes used by downloaded regions alone (sum of per-region sizes + /// from the native SDK). The difference between this and [_totalCacheBytes] + /// is the ambient/auto-cached tile portion. + int _downloadsBytes = 0; + + /// Total on-disk cache size (downloads + ambient, bytes). + int get totalUsedBytes => _totalCacheBytes; + + /// Bytes attributed to explicit offline downloads. + int get downloadsBytes => _downloadsBytes; + + /// Bytes attributed to tiles cached opportunistically while panning/zooming + /// the map (total minus explicit downloads, clamped ≥ 0 because the two + /// numbers come from different native queries and can briefly diverge). + int get ambientBytes => + (_totalCacheBytes - _downloadsBytes).clamp(0, _totalCacheBytes); + + double get usageRatio { + if (storageLimitBytes == 0) return 0; + return (totalUsedBytes / storageLimitBytes).clamp(0.0, 1.0); + } + + /// Ratio of the storage bar filled by each bucket. Both clamp to [0,1] and + /// are computed against the limit so they visually add up against the same + /// denominator as [usageRatio]. + double get downloadsRatio { + if (storageLimitBytes == 0) return 0; + return (_downloadsBytes / storageLimitBytes).clamp(0.0, 1.0); + } + + double get ambientRatio { + if (storageLimitBytes == 0) return 0; + return (ambientBytes / storageLimitBytes).clamp(0.0, 1.0); + } + + String get totalUsedDisplay => _formatBytes(totalUsedBytes); + String get downloadsDisplay => _formatBytes(_downloadsBytes); + String get ambientDisplay => _formatBytes(ambientBytes); + String get storageLimitDisplay => '$_storageLimitMb MB'; + + // ── Download state ── + + /// Currently active download progress (null if idle). + double? _downloadProgress; + double? get downloadProgress => _downloadProgress; + + String? _downloadingRegionName; + String? get downloadingRegionName => _downloadingRegionName; + + bool get isDownloading => _downloadProgress != null; + + /// ID of the region currently being downloaded. Set right after MapLibre + /// accepts the download; null when idle. Used by [cancelActiveDownload] to + /// delete the partial region via `deleteOfflineRegion`. + int? _activeRegionId; + + /// Completer tied to the currently-active download. Resolved when + /// [_onDownloadEvent] sees Success or Error, or when the download is + /// cancelled. + Completer? _activeCompleter; + + /// Estimated total bytes for the currently-active download, used by the + /// quota pre-check so queued jobs can't jointly bust the limit. + int _activeEstBytes = 0; + + /// Rolling window of recent (timestamp, progress 0-1) samples used to + /// compute smoothed download speed and ETA. Oldest-first. Cleared on + /// download start/end/cancel. + final List<({DateTime at, double progress})> _progressSamples = []; + static const int _progressWindowSize = 5; + + /// Instantaneous download speed in bytes/sec, computed over the last few + /// progress events. Null until at least two samples are available. + double? get downloadBytesPerSecond { + if (_progressSamples.length < 2 || _activeEstBytes <= 0) return null; + final first = _progressSamples.first; + final last = _progressSamples.last; + final seconds = last.at.difference(first.at).inMilliseconds / 1000.0; + if (seconds <= 0) return null; + final deltaBytes = (last.progress - first.progress) * _activeEstBytes; + if (deltaBytes <= 0) return null; + return deltaBytes / seconds; + } + + /// Estimated time remaining based on the current smoothed speed. Null if + /// no speed data yet, or if the download is effectively stalled. + /// Capped at 99 minutes to keep the display sane during warm-up wobbles. + Duration? get downloadEta { + final bps = downloadBytesPerSecond; + final progress = _downloadProgress; + if (bps == null || bps <= 0 || progress == null) return null; + final remainingBytes = (1.0 - progress) * _activeEstBytes; + if (remainingBytes <= 0) return Duration.zero; + final seconds = (remainingBytes / bps).round(); + return Duration(seconds: seconds.clamp(0, 99 * 60)); + } + + /// FIFO queue of pending downloads. Drained one at a time after the + /// current download finishes (or is cancelled). + final List<_QueuedDownload> _queue = []; + int get queueLength => _queue.length; + + String? _lastError; + String? get lastError => _lastError; + + /// Read [lastError] and clear it. Use this in UI that displays the error + /// once (banner, toast) so it doesn't linger after dismissal. + String? consumeLastError() { + final e = _lastError; + _lastError = null; + return e; + } + + /// Name of the most recently completed download (for one-shot UI toast). + /// Call [consumeLastCompletedName] to read and clear. + String? _lastCompletedName; + String? consumeLastCompletedName() { + final name = _lastCompletedName; + _lastCompletedName = null; + return name; + } + + bool _initialized = false; + bool get initialized => _initialized; + + // ── Initialization ── + + /// Initialize: create notification channel, load storage limit, refresh list. + /// Safe to call multiple times — subsequent calls are no-ops. + Future initialize() async { + if (kIsWeb) return; + if (_initialized) return; + try { + await _initNotifications(); + final prefs = await SharedPreferences.getInstance(); + _storageLimitMb = prefs.getInt(_storageLimitKey) ?? defaultStorageLimitMb; + await refreshRegions(); + _initialized = true; + notifyListeners(); + } catch (e) { + debugError('[OFFLINE_MAP] Init error: $e'); + notifyListeners(); + } + } + + /// Set up the Android notification channel for download progress. + Future _initNotifications() async { + if (_notifInitialized) return; + try { + const AndroidNotificationChannel channel = AndroidNotificationChannel( + _notifChannelId, + _notifChannelName, + description: 'Shows progress when downloading offline map tiles', + importance: Importance.low, // No sound/vibration + showBadge: false, + ); + + await _notifPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.createNotificationChannel(channel); + + // Initialize the plugin (required before showing notifications). + const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher'); + const iosInit = DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + requestSoundPermission: false, + ); + await _notifPlugin.initialize( + const InitializationSettings(android: androidInit, iOS: iosInit), + ); + _notifInitialized = true; + } catch (e) { + debugError('[OFFLINE_MAP] Notification init error: $e'); + } + } + + // ── Notifications ── + + Future _showProgressNotification(String regionName, int percent) async { + if (!_notifInitialized) return; + try { + final androidDetails = AndroidNotificationDetails( + _notifChannelId, + _notifChannelName, + channelDescription: 'Shows progress when downloading offline map tiles', + importance: Importance.low, + priority: Priority.low, + showProgress: true, + maxProgress: 100, + progress: percent, + ongoing: true, // Non-dismissable while downloading + autoCancel: false, + onlyAlertOnce: true, // Don't buzz on every update + icon: '@mipmap/ic_launcher', + ); + + await _notifPlugin.show( + _progressNotifId, + 'Downloading "$regionName"', + '$percent% complete', + NotificationDetails(android: androidDetails), + ); + } catch (e) { + debugWarn('[OFFLINE_MAP] Failed to show progress notification: $e'); + } + } + + Future _showCompleteNotification(String regionName) async { + await _dismissProgressNotification(); + if (!_notifInitialized) return; + try { + const androidDetails = AndroidNotificationDetails( + _notifChannelId, + _notifChannelName, + channelDescription: 'Shows progress when downloading offline map tiles', + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + icon: '@mipmap/ic_launcher', + ); + + await _notifPlugin.show( + _completeNotifId, + 'Download Complete', + '"$regionName" is ready for offline use', + const NotificationDetails(android: androidDetails), + ); + } catch (e) { + debugWarn('[OFFLINE_MAP] Failed to show complete notification: $e'); + } + } + + Future _showErrorNotification(String regionName) async { + await _dismissProgressNotification(); + if (!_notifInitialized) return; + try { + const androidDetails = AndroidNotificationDetails( + _notifChannelId, + _notifChannelName, + channelDescription: 'Shows progress when downloading offline map tiles', + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + icon: '@mipmap/ic_launcher', + ); + + await _notifPlugin.show( + _completeNotifId, + 'Download Failed', + 'Failed to download "$regionName"', + const NotificationDetails(android: androidDetails), + ); + } catch (e) { + debugWarn('[OFFLINE_MAP] Failed to show error notification: $e'); + } + } + + Future _dismissProgressNotification() async { + try { + await _notifPlugin.cancel(_progressNotifId); + } catch (e) { + debugWarn('[OFFLINE_MAP] Failed to dismiss notification: $e'); + } + } + + // ── Region queries ── + + /// Refresh the list of downloaded regions from MapLibre native storage, + /// along with per-region byte counts and the overall on-disk cache size. + Future refreshRegions() async { + if (kIsWeb) return; + try { + final rawRegions = await getListOfRegions(); + // Pull sizes and the total cache footprint in parallel. Both come from + // our platform channel so they share the same round-trip cost. + final results = await Future.wait([ + TileCacheService.instance.getRegionSizes(), + TileCacheService.instance.getCacheSizeBytes(), + ]); + final sizes = results[0] as Map; + final totalBytes = results[1] as int; + + final parsed = []; + int downloadsSum = 0; + for (final r in rawRegions) { + try { + final actual = sizes[r.id]; + parsed + .add(OfflineMapRegion.fromOfflineRegion(r, actualBytes: actual)); + downloadsSum += actual ?? 0; + } catch (e) { + debugWarn('[OFFLINE_MAP] Failed to parse region ${r.id}: $e'); + } + } + _regions = parsed; + _regions.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + _totalCacheBytes = totalBytes; + _downloadsBytes = downloadsSum; + notifyListeners(); + } catch (e) { + debugError('[OFFLINE_MAP] Failed to list regions: $e'); + } + } + + /// Refresh only the overall cache size (fast path, no region enumeration). + /// Useful after ambient cache operations that don't change region state. + Future refreshCacheSize() async { + if (kIsWeb) return; + _totalCacheBytes = await TileCacheService.instance.getCacheSizeBytes(); + notifyListeners(); + } + + // ── Storage limit ── + + /// Update the storage limit (in MB) and persist it. + Future setStorageLimit(int limitMb) async { + _storageLimitMb = limitMb.clamp(minStorageLimitMb, maxStorageLimitMb); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_storageLimitKey, _storageLimitMb); + } catch (e) { + debugError('[OFFLINE_MAP] Failed to save storage limit: $e'); + } + notifyListeners(); + } + + // ── Tile estimation ── + + /// Native max zoom of the OpenFreeMap vector tileset. z15+ in the slider is + /// reachable but it's pure overzoom — MapLibre rasterizes z14 source tiles + /// for higher zooms without fetching new data. Capping the estimator loop + /// here keeps overzoom from being counted as additional downloads. + static const int _vectorTilesetMaxZoom = 14; + + /// Web Mercator clamps latitude to ±~85.0511° (atan(sinh(π))); beyond that + /// the projection diverges. Using the literal here keeps the tile-Y math + /// stable if a user drags a corner near a pole. + static const double _mercatorLatLimit = 85.0511; + + /// Average bytes per OpenFreeMap vector tile (MVT/PBF). Empirically ~2.4 KB + /// globally; we round up to 3 KB to leave a small margin without grossly + /// overestimating. Raster tiles would need a higher number (20–40 KB) but + /// the app only offers vector styles for offline download — if that ever + /// changes, this needs to be style-aware. + static const int _bytesPerVectorTile = 3 * 1024; + + /// Estimate tile count for a region using proper Web Mercator tile math. + /// Iterates over each zoom level the SDK will actually fetch source tiles + /// for (capped at the tileset's native max zoom). + static int estimateTileCount( + LatLngBounds bounds, double minZoom, double maxZoom) { + final zMin = minZoom.floor(); + final zMax = math.min(maxZoom.ceil(), _vectorTilesetMaxZoom); + if (zMax < zMin) return 0; + + final latMin = + bounds.southwest.latitude.clamp(-_mercatorLatLimit, _mercatorLatLimit); + final latMax = + bounds.northeast.latitude.clamp(-_mercatorLatLimit, _mercatorLatLimit); + final lngMin = bounds.southwest.longitude; + final lngMax = bounds.northeast.longitude; + + int total = 0; + for (int z = zMin; z <= zMax; z++) { + final n = 1 << z; + final x0 = ((lngMin + 180.0) / 360.0 * n).floor().clamp(0, n - 1); + final x1 = ((lngMax + 180.0) / 360.0 * n).floor().clamp(0, n - 1); + final xTiles = (x1 - x0 + 1).clamp(1, n); + // North latitude → smaller tile Y in Mercator, so yNorth uses latMax. + final yNorth = _mercatorTileY(latMax, n); + final ySouth = _mercatorTileY(latMin, n); + final yTiles = (ySouth - yNorth + 1).clamp(1, n); + total += xTiles * yTiles; + } + return total; + } + + /// Web Mercator tile-Y index for a given latitude at zoom level `2^z = n`. + static int _mercatorTileY(double latDeg, int n) { + final latRad = latDeg * math.pi / 180.0; + final y = + (1 - math.log(math.tan(latRad) + 1 / math.cos(latRad)) / math.pi) / + 2 * + n; + return y.floor().clamp(0, n - 1); + } + + /// Estimate download size in bytes from tile count. + static int estimateSizeBytes(int tileCount) => + tileCount * _bytesPerVectorTile; + + /// Check if downloading a region of [estimatedBytes] would exceed the + /// limit. Accounts for the currently-active download and any queued jobs + /// so the UI's pre-check agrees with the service's internal validation. + bool wouldExceedLimit(int estimatedBytes) { + final pending = (isDownloading ? _activePendingBytes : 0) + + _queue.fold(0, (sum, q) => sum + q.estBytes); + return (totalUsedBytes + pending + estimatedBytes) > storageLimitBytes; + } + + /// Rough bytes remaining in the currently-active download. We don't know + /// the real size until completion — use the job's estimate as a proxy by + /// subtracting progress. + int get _activePendingBytes { + final progress = _downloadProgress ?? 0; + return ((1 - progress) * _activeEstBytes).clamp(0, _activeEstBytes).toInt(); + } + + // ── Download ── + + /// Download an offline region. + /// + /// The download runs in MapLibre's native layer, so it survives Flutter + /// screen navigation. This service (kept alive by the app-level Provider) + /// receives progress callbacks and forwards them to both [notifyListeners] + /// and a system notification. + /// + /// If another download is already active, the request is appended to a + /// FIFO queue and will start automatically when the current one finishes + /// or is cancelled. + /// + /// Returns the new [OfflineMapRegion] on success, null on failure or if + /// validation rejects the request (quota, free space, antimeridian, etc.). + /// The returned future resolves once the region's native download fully + /// completes — not when it's merely queued. + Future downloadRegion({ + required String name, + required LatLngBounds bounds, + required String styleUrl, + required String styleName, + double minZoom = 0, + double maxZoom = 14, + }) async { + if (kIsWeb) return null; + + final tileCount = estimateTileCount(bounds, minZoom, maxZoom); + final estBytes = estimateSizeBytes(tileCount); + + if (wouldExceedLimit(estBytes)) { + _lastError = + 'Download would exceed storage limit (${_formatBytes(estBytes)} ' + 'needed)'; + notifyListeners(); + return null; + } + + // Device free-space check. The app-level quota above only caps estimated + // tile usage across our regions — the device itself may be nearly full + // regardless. We require 1.5× the estimate to leave headroom for the + // heuristic being low. Failures of the platform call fall through to + // the existing quota logic. + try { + final freeMb = await DiskSpacePlus().getFreeDiskSpace; + if (freeMb != null) { + final freeBytes = (freeMb * 1024 * 1024).toInt(); + final required = (estBytes * 1.5).toInt(); + if (freeBytes < required) { + _lastError = 'Not enough free space on device ' + '(${_formatBytes(required)} needed, ' + '${_formatBytes(freeBytes)} free)'; + notifyListeners(); + return null; + } + } + } catch (e) { + debugWarn('[OFFLINE_MAP] Free-space check failed: $e'); + } + + final job = _QueuedDownload( + name: name, + bounds: bounds, + styleUrl: styleUrl, + styleName: styleName, + minZoom: minZoom, + maxZoom: maxZoom, + estBytes: estBytes, + completer: Completer(), + ); + + if (isDownloading) { + _queue.add(job); + debugLog('[OFFLINE_MAP] Queued "$name" ' + '(position ${_queue.length} in queue)'); + notifyListeners(); + } else { + unawaited(_startJob(job)); + } + + return job.completer.future; + } + + /// Start a queued job. Also invoked recursively from [_onDownloadEvent] + /// after the active download finishes, to drain the queue. + Future _startJob(_QueuedDownload job) async { + _downloadProgress = 0; + _downloadingRegionName = job.name; + _activeCompleter = job.completer; + _activeEstBytes = job.estBytes; + _progressSamples.clear(); + _lastError = null; + _lastCompletedName = null; + notifyListeners(); + _showProgressNotification(job.name, 0); + + try { + final definition = OfflineRegionDefinition( + bounds: job.bounds, + mapStyleUrl: job.styleUrl, + minZoom: job.minZoom, + maxZoom: job.maxZoom, + ); + + final metadata = { + _MetaKeys.name: job.name, + _MetaKeys.styleName: job.styleName, + _MetaKeys.createdAt: DateTime.now().toIso8601String(), + _MetaKeys.estimatedBytes: job.estBytes, + }; + + final region = await downloadOfflineRegion( + definition, + metadata: metadata, + onEvent: _onDownloadEvent, + ); + _activeRegionId = region.id; + + // downloadOfflineRegion resolves once MapLibre has accepted the job; + // progress and completion are delivered via _onDownloadEvent. + // In the rare case Success already fired synchronously before this + // returns, _downloadProgress will be null — resolve the completer now + // so the caller doesn't hang waiting for an event that already fired. + if (_downloadProgress == null && !job.completer.isCompleted) { + final finalized = _regions.firstWhere((r) => r.id == region.id, + orElse: () => OfflineMapRegion.fromOfflineRegion(region)); + job.completer.complete(finalized); + } + } catch (e) { + debugError('[OFFLINE_MAP] downloadRegion threw: $e'); + _downloadProgress = null; + _downloadingRegionName = null; + _activeRegionId = null; + _activeCompleter = null; + _activeEstBytes = 0; + _progressSamples.clear(); + _lastError = 'Download failed (${e.runtimeType}): $e'; + notifyListeners(); + _showErrorNotification(job.name); + if (!job.completer.isCompleted) job.completer.complete(null); + _drainQueue(); + } + } + + /// Start the next queued download if any, once the current one has + /// finished, errored, or been cancelled. + void _drainQueue() { + if (_queue.isEmpty) return; + final next = _queue.removeAt(0); + unawaited(_startJob(next)); + } + + void _onDownloadEvent(DownloadRegionStatus status) { + if (status is Success) { + final name = _downloadingRegionName ?? 'Region'; + final completer = _activeCompleter; + final regionId = _activeRegionId; + _downloadProgress = null; + _downloadingRegionName = null; + _activeRegionId = null; + _activeCompleter = null; + _activeEstBytes = 0; + _progressSamples.clear(); + _lastCompletedName = name; + notifyListeners(); + _showCompleteNotification(name); + // Small delay lets the native DB commit before we query it. + Future.delayed(const Duration(milliseconds: 500), () async { + await refreshRegions(); + if (completer != null && !completer.isCompleted) { + OfflineMapRegion? finalized; + if (regionId != null) { + for (final r in _regions) { + if (r.id == regionId) { + finalized = r; + break; + } + } + } + completer.complete(finalized); + } + _drainQueue(); + }); + } else if (status is InProgress) { + final progress = status.progress / 100.0; + _downloadProgress = progress; + _progressSamples.add((at: DateTime.now(), progress: progress)); + if (_progressSamples.length > _progressWindowSize) { + _progressSamples.removeAt(0); + } + notifyListeners(); + // Throttle notification updates to every 2% to avoid flooding + final percent = status.progress.round(); + if (percent % 2 == 0) { + _showProgressNotification(_downloadingRegionName ?? 'Region', percent); + } + } else { + // DownloadRegionStatus is sealed to Success / InProgress / Error. The + // concrete Error class is named `Error` in maplibre_gl, which collides + // with dart:core.Error — so rather than `status is Error` we reach the + // `cause` field dynamically. `cause` is a PlatformException with + // code/message/details we can surface to the user. + final name = _downloadingRegionName ?? 'Region'; + final completer = _activeCompleter; + String detail = 'unknown error'; + try { + final cause = (status as dynamic).cause; + if (cause != null) { + final msg = (cause as dynamic).message; + final code = (cause as dynamic).code; + if (msg is String && msg.isNotEmpty) { + detail = msg; + } else if (code is String && code.isNotEmpty) { + detail = code; + } else { + detail = cause.toString(); + } + } + } catch (_) { + // Swallow — keep the generic 'unknown error' fallback. + } + debugError('[OFFLINE_MAP] Download failed: $detail'); + _downloadProgress = null; + _downloadingRegionName = null; + _activeRegionId = null; + _activeCompleter = null; + _activeEstBytes = 0; + _progressSamples.clear(); + _lastError = 'Download failed: $detail'; + _showErrorNotification(name); + notifyListeners(); + if (completer != null && !completer.isCompleted) { + completer.complete(null); + } + _drainQueue(); + } + } + + /// Cancel the currently-active download, if any. Deletes the partial + /// region from MapLibre's cache. Queued downloads are left in place — + /// the next one will start automatically. Call [clearQueue] to discard + /// queued jobs too. + Future cancelActiveDownload() async { + if (kIsWeb) return false; + final regionId = _activeRegionId; + final completer = _activeCompleter; + final name = _downloadingRegionName; + if (regionId == null && !isDownloading) return false; + + debugLog('[OFFLINE_MAP] Cancelling active download' + '${name != null ? " \"$name\"" : ""}'); + + if (regionId != null) { + try { + await deleteOfflineRegion(regionId); + } catch (e) { + debugWarn('[OFFLINE_MAP] Cancel delete failed: $e'); + } + } + + _downloadProgress = null; + _downloadingRegionName = null; + _activeRegionId = null; + _activeCompleter = null; + _activeEstBytes = 0; + _progressSamples.clear(); + await _dismissProgressNotification(); + notifyListeners(); + + if (completer != null && !completer.isCompleted) { + completer.complete(null); + } + + await refreshRegions(); + _drainQueue(); + return true; + } + + /// Discard all queued (but not yet active) downloads. Does not affect + /// the active download; call [cancelActiveDownload] for that. + void clearQueue() { + if (_queue.isEmpty) return; + for (final job in _queue) { + if (!job.completer.isCompleted) job.completer.complete(null); + } + _queue.clear(); + notifyListeners(); + } + + // ── Deletion ── + + /// Delete a downloaded region by ID. + Future deleteRegion(int regionId) async { + if (kIsWeb) return false; + try { + await deleteOfflineRegion(regionId); + await refreshRegions(); + return true; + } catch (e) { + debugError('[OFFLINE_MAP] Delete failed: $e'); + _lastError = 'Failed to delete region: $e'; + notifyListeners(); + return false; + } + } + + /// Delete all downloaded regions. + Future deleteAllRegions() async { + if (kIsWeb) return; + final ids = _regions.map((r) => r.id).toList(); + for (final id in ids) { + try { + await deleteOfflineRegion(id); + } catch (e) { + debugError('[OFFLINE_MAP] Failed to delete region $id: $e'); + } + } + await refreshRegions(); + } + + // ── Cleanup ── + + /// Cancel any stale progress notification from a previous session. + /// Called at app startup (mirrors BackgroundServiceManager.cleanupOrphanedService). + Future cleanupOrphanedNotification() async { + if (kIsWeb) return; + try { + await _initNotifications(); + await _notifPlugin.cancel(_progressNotifId); + } catch (e) { + debugWarn('[OFFLINE_MAP] Failed to cleanup orphaned notification: $e'); + } + } + + static 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'; + } +} diff --git a/lib/services/offline_session_service.dart b/lib/services/offline_session_service.dart index 282d911d..db4c0789 100644 --- a/lib/services/offline_session_service.dart +++ b/lib/services/offline_session_service.dart @@ -10,9 +10,19 @@ class OfflineSession { final DateTime createdAt; final int pingCount; final Map data; - final String? devicePublicKey; // Device public key for auth during upload - final String? deviceName; // Device name for display - final bool uploaded; // Track upload status + final String? devicePublicKey; // Device public key for auth during upload + final String? deviceName; // Device name for display + final String? contactUri; // Signed contact URI for registration during upload + final String? + radioConfig; // Radio config captured at record time ("910.525,62.5,7,5") + final String? + deviceModel; // Device model/manufacturer captured at record time + final double? powerLevel; // TX power level (watts) captured at record time + final String? appVersion; // App version captured at record time + final bool uploaded; // Track upload status + final Map? + placementCounts; // Per-IATA counts the server routed this upload into + final int? tooFarRegion; // Pings dropped for being >50km outside every region OfflineSession({ required this.filename, @@ -21,7 +31,14 @@ class OfflineSession { required this.data, this.devicePublicKey, this.deviceName, + this.contactUri, + this.radioConfig, + this.deviceModel, + this.powerLevel, + this.appVersion, this.uploaded = false, + this.placementCounts, + this.tooFarRegion, }); /// Create from stored JSON @@ -33,7 +50,15 @@ class OfflineSession { data: json['data'] as Map, devicePublicKey: json['devicePublicKey'] as String?, deviceName: json['deviceName'] as String?, + contactUri: json['contactUri'] as String?, + radioConfig: json['radioConfig'] as String?, + deviceModel: json['deviceModel'] as String?, + powerLevel: (json['powerLevel'] as num?)?.toDouble(), + appVersion: json['appVersion'] as String?, uploaded: json['uploaded'] as bool? ?? false, + placementCounts: (json['placementCounts'] as Map?) + ?.map((k, v) => MapEntry(k.toString(), (v as num).toInt())), + tooFarRegion: json['tooFarRegion'] as int?, ); } @@ -46,20 +71,40 @@ class OfflineSession { 'data': data, 'devicePublicKey': devicePublicKey, 'deviceName': deviceName, + 'contactUri': contactUri, + 'radioConfig': radioConfig, + 'deviceModel': deviceModel, + 'powerLevel': powerLevel, + 'appVersion': appVersion, 'uploaded': uploaded, + 'placementCounts': placementCounts, + 'tooFarRegion': tooFarRegion, }; } - /// Create a copy with uploaded status changed - OfflineSession copyWith({bool? uploaded}) { + /// Create a copy with updated fields + OfflineSession copyWith({ + bool? uploaded, + Map? data, + int? pingCount, + Map? placementCounts, + int? tooFarRegion, + }) { return OfflineSession( filename: filename, createdAt: createdAt, - pingCount: pingCount, - data: data, + pingCount: pingCount ?? this.pingCount, + data: data ?? this.data, devicePublicKey: devicePublicKey, deviceName: deviceName, + contactUri: contactUri, + radioConfig: radioConfig, + deviceModel: deviceModel, + powerLevel: powerLevel, + appVersion: appVersion, uploaded: uploaded ?? this.uploaded, + placementCounts: placementCounts ?? this.placementCounts, + tooFarRegion: tooFarRegion ?? this.tooFarRegion, ); } @@ -78,6 +123,10 @@ class OfflineSessionService { SharedPreferences? _prefs; List _sessions = []; + /// Tracks the filename of the session currently being accumulated via periodic auto-save. + /// When set, `updateCurrentSession()` updates this session in-place instead of creating a new one. + String? _currentSessionFilename; + /// Callback when sessions list changes void Function(List sessions)? onSessionsUpdated; @@ -97,14 +146,18 @@ class OfflineSessionService { /// Load sessions from storage Future _loadSessions() async { final sessionsJson = _prefs?.getStringList(_sessionsKey) ?? []; - _sessions = sessionsJson.map((json) { - try { - return OfflineSession.fromJson(jsonDecode(json) as Map); - } catch (e) { - debugError('[OFFLINE] Failed to parse session: $e'); - return null; - } - }).whereType().toList(); + _sessions = sessionsJson + .map((json) { + try { + return OfflineSession.fromJson( + jsonDecode(json) as Map); + } catch (e) { + debugError('[OFFLINE] Failed to parse session: $e'); + return null; + } + }) + .whereType() + .toList(); // Sort by date, newest first _sessions.sort((a, b) => b.createdAt.compareTo(a.createdAt)); @@ -120,10 +173,12 @@ class OfflineSessionService { /// Generate filename for new session String _generateFilename() { final now = DateTime.now(); - final dateStr = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; + final dateStr = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; // Check if we already have sessions for today - final todaySessions = _sessions.where((s) => s.filename.startsWith(dateStr)).length; + final todaySessions = + _sessions.where((s) => s.filename.startsWith(dateStr)).length; if (todaySessions == 0) { return '$dateStr.json'; @@ -141,6 +196,11 @@ class OfflineSessionService { List> pings, { String? devicePublicKey, String? deviceName, + String? contactUri, + String? radioConfig, + String? deviceModel, + double? powerLevel, + String? appVersion, }) async { if (pings.isEmpty) { debugLog('[OFFLINE] No pings to save, skipping session creation'); @@ -158,6 +218,10 @@ class OfflineSessionService { 'pings': pings, if (devicePublicKey != null) 'device_public_key': devicePublicKey, if (deviceName != null) 'device_name': deviceName, + if (radioConfig != null) 'radio_config': radioConfig, + if (deviceModel != null) 'device_model': deviceModel, + if (powerLevel != null) 'power_level': powerLevel, + if (appVersion != null) 'app_version': appVersion, }; final session = OfflineSession( @@ -167,27 +231,174 @@ class OfflineSessionService { data: sessionData, devicePublicKey: devicePublicKey, deviceName: deviceName, + contactUri: contactUri, + radioConfig: radioConfig, + deviceModel: deviceModel, + powerLevel: powerLevel, + appVersion: appVersion, ); _sessions.insert(0, session); // Add at beginning (newest first) await _saveSessions(); - debugLog('[OFFLINE] Saved session: $filename with ${pings.length} pings (device: ${deviceName ?? "unknown"})'); + debugLog( + '[OFFLINE] Saved session: $filename with ${pings.length} pings (device: ${deviceName ?? "unknown"})'); + } + + /// Update the current in-progress session with the latest pings snapshot. + /// If no current session exists, creates a new one and tracks it. + /// This allows periodic saves to update the same file instead of creating duplicates. + Future updateCurrentSession( + List> pings, { + String? devicePublicKey, + String? deviceName, + String? contactUri, + String? radioConfig, + String? deviceModel, + double? powerLevel, + String? appVersion, + }) async { + if (pings.isEmpty) { + debugLog('[OFFLINE] No pings to auto-save, skipping'); + return; + } + + // If we have a tracked session, update it in-place + if (_currentSessionFilename != null) { + final index = + _sessions.indexWhere((s) => s.filename == _currentSessionFilename); + if (index != -1) { + final existing = _sessions[index]; + final updatedData = Map.from(existing.data); + updatedData['pings'] = pings; + updatedData['ping_count'] = pings.length; + final effectiveRadioConfig = radioConfig ?? existing.radioConfig; + if (effectiveRadioConfig != null) { + updatedData['radio_config'] = effectiveRadioConfig; + } + final effectiveDeviceModel = deviceModel ?? existing.deviceModel; + if (effectiveDeviceModel != null) { + updatedData['device_model'] = effectiveDeviceModel; + } + final effectivePowerLevel = powerLevel ?? existing.powerLevel; + if (effectivePowerLevel != null) { + updatedData['power_level'] = effectivePowerLevel; + } + final effectiveAppVersion = appVersion ?? existing.appVersion; + if (effectiveAppVersion != null) { + updatedData['app_version'] = effectiveAppVersion; + } + + _sessions[index] = OfflineSession( + filename: existing.filename, + createdAt: existing.createdAt, + pingCount: pings.length, + data: updatedData, + devicePublicKey: devicePublicKey ?? existing.devicePublicKey, + deviceName: deviceName ?? existing.deviceName, + contactUri: contactUri ?? existing.contactUri, + radioConfig: effectiveRadioConfig, + deviceModel: effectiveDeviceModel, + powerLevel: effectivePowerLevel, + appVersion: effectiveAppVersion, + ); + await _saveSessions(); + debugLog( + '[OFFLINE] Updated session: ${existing.filename} with ${pings.length} pings'); + return; + } + // Session was deleted externally — fall through to create new + debugWarn( + '[OFFLINE] Tracked session $_currentSessionFilename not found, creating new'); + _currentSessionFilename = null; + } + + // No current session — create a new one and track it + await saveSession( + pings, + devicePublicKey: devicePublicKey, + deviceName: deviceName, + contactUri: contactUri, + radioConfig: radioConfig, + deviceModel: deviceModel, + powerLevel: powerLevel, + appVersion: appVersion, + ); + // saveSession inserts at index 0 (newest first) + if (_sessions.isNotEmpty) { + _currentSessionFilename = _sessions.first.filename; + debugLog( + '[OFFLINE] Tracking new auto-save session: $_currentSessionFilename'); + } } - /// Mark a session as uploaded without deleting it - Future markAsUploaded(String filename) async { + /// Clear the current session tracker so the next save creates a fresh session file. + /// Called after final saves (mode switch, disconnect) to create a clean break. + void finalizeCurrentSession() { + if (_currentSessionFilename != null) { + debugLog('[OFFLINE] Finalized session: $_currentSessionFilename'); + _currentSessionFilename = null; + } + } + + /// Mark a session as uploaded without deleting it. Optionally records the + /// per-region placement summary + too-far count returned by the server. + Future markAsUploaded( + String filename, { + Map? placementCounts, + int? tooFarRegion, + }) async { final index = _sessions.indexWhere((s) => s.filename == filename); if (index == -1) { debugLog('[OFFLINE] Session not found for marking uploaded: $filename'); return; } - _sessions[index] = _sessions[index].copyWith(uploaded: true); + _sessions[index] = _sessions[index].copyWith( + uploaded: true, + placementCounts: placementCounts, + tooFarRegion: tooFarRegion, + ); await _saveSessions(); debugLog('[OFFLINE] Marked session as uploaded: $filename'); } + /// Remove the first [processedCount] pings from a session. + /// Called after partial upload so retries don't re-send already-processed data. + /// Returns the number of remaining pings, or null if session not found. + Future removeProcessedPings(String filename, int processedCount) async { + final index = _sessions.indexWhere((s) => s.filename == filename); + if (index == -1) { + debugWarn('[OFFLINE] Session not found for ping removal: $filename'); + return null; + } + + final session = _sessions[index]; + final pings = (session.data['pings'] as List?) ?? []; + + if (processedCount >= pings.length) { + _sessions[index] = session.copyWith(uploaded: true); + await _saveSessions(); + debugLog( + '[OFFLINE] All pings processed, marking session complete: $filename'); + return 0; + } + + final remaining = pings.sublist(processedCount); + final updatedData = Map.from(session.data); + updatedData['pings'] = remaining; + updatedData['ping_count'] = remaining.length; + + _sessions[index] = session.copyWith( + data: updatedData, + pingCount: remaining.length, + ); + await _saveSessions(); + debugLog( + '[OFFLINE] Removed $processedCount processed pings from $filename, ${remaining.length} remain'); + return remaining.length; + } + /// Get a session by filename OfflineSession? getSession(String filename) { try { diff --git a/lib/services/permission_disclosure_service.dart b/lib/services/permission_disclosure_service.dart index 1fd5d8ad..c6ca111a 100644 --- a/lib/services/permission_disclosure_service.dart +++ b/lib/services/permission_disclosure_service.dart @@ -44,7 +44,8 @@ class PermissionDisclosureService { style: TextStyle(fontWeight: FontWeight.w600), ), SizedBox(height: 12), - _BulletPoint(text: 'Track where you send pings on the mesh network'), + _BulletPoint( + text: 'Track where you send pings on the mesh network'), _BulletPoint(text: 'Map coverage areas for the community'), _BulletPoint(text: 'Record which repeaters hear your device'), SizedBox(height: 16), @@ -79,7 +80,8 @@ class PermissionDisclosureService { /// Show the background location disclosure (for "Always" permission) /// Returns true if user accepts, false if they decline - static Future showBackgroundLocationDisclosure(BuildContext context) async { + static Future showBackgroundLocationDisclosure( + BuildContext context) async { final result = await showDialog( context: context, barrierDismissible: false, @@ -103,8 +105,12 @@ class PermissionDisclosureService { style: TextStyle(fontWeight: FontWeight.w600), ), SizedBox(height: 12), - _BulletPoint(text: 'Continue tracking coverage while the app is minimized'), - _BulletPoint(text: 'Send automatic pings during extended wardriving sessions'), + _BulletPoint( + text: + 'Continue tracking coverage while the app is minimized'), + _BulletPoint( + text: + 'Send automatic pings during extended wardriving sessions'), SizedBox(height: 16), Text( 'This grants "always on" location access, but we only collect what\'s needed: tagging pings while wardriving and checking if you\'re in a supported zone.', diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index 54513f4e..bbeff298 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:geolocator/geolocator.dart'; @@ -12,7 +13,10 @@ import 'countdown_timer_service.dart'; import 'gps_service.dart'; import 'meshcore/connection.dart'; import 'meshcore/disc_tracker.dart'; +import 'meshcore/trace_tracker.dart'; import 'meshcore/tx_tracker.dart'; +import 'meshcore/wire_tag_codec.dart'; +import 'meshcore/unified_rx_handler.dart'; import 'wakelock_service.dart'; /// Ping service for TX/RX ping orchestration @@ -39,15 +43,22 @@ import 'wakelock_service.dart'; class PingService { /// RX listening window duration (5 seconds - matches cooldown duration) static const Duration _rxListeningWindow = Duration(seconds: 5); + /// Cooldown period between pings (5 seconds) static const Duration _autoPingCooldown = Duration(seconds: 5); - /// Discovery listening window duration (5 seconds) - static const Duration _discoveryListeningWindow = Duration(seconds: 5); + + /// Discovery listening window duration (7 seconds) + static const Duration _discoveryListeningWindow = Duration(seconds: 7); + /// Discovery request interval (30 seconds - repeaters only respond 4 times per 2 minutes) static const Duration _discoveryInterval = Duration(seconds: 30); + /// Cooldown period between manual pings (15 seconds) static const Duration _manualPingCooldown = Duration(seconds: 15); + /// Current configured min ping distance (for validation messages) + static int currentMinDistance = 25; + final GpsService _gpsService; final MeshCoreConnection _connection; final ApiQueueService _apiQueue; @@ -61,9 +72,24 @@ class PingService { final AudioService? _audioService; final bool Function(String repeaterId)? shouldIgnoreRepeater; + /// Number of bytes per hop in path hash (1, 2, or 3). Passed to DiscTracker for repeater ID length. + int _hopBytes; + + /// Number of bytes for trace path IDs (1, 2, or 4). Uses bitshift encoding, separate from TX. + int _traceHopBytes; + + /// Update hop bytes at runtime (e.g. when user changes path mode while connected) + set hopBytes(int value) => _hopBytes = value; + + /// Update trace hop bytes at runtime (e.g. when user changes trace byte setting) + set traceHopBytes(int value) => _traceHopBytes = value; + /// When true, skip RSSI carpeater check in DiscTracker (user setting) bool disableRssiFilter; + /// Unified RX handler reference for routing trace packets + UnifiedRxHandler? unifiedRxHandler; + PingStats _stats = const PingStats(); DateTime? _lastTxTime; Timer? _rxWindowTimer; @@ -71,6 +97,8 @@ class PingService { // TX ping context for queueing after RX window ends int? _pendingTxTimestamp; int? _pendingTxNoiseFloor; + int? _pendingTxPingCounter; // wire-tag ping counter (null in coords mode) + String? _pendingTxWireTag; // wire-tag body sent on air (null in coords mode) // Ping in progress guard (prevents concurrent BLE GATT errors) // Reference: state.pingInProgress in wardrive.js @@ -80,12 +108,31 @@ class PingService { bool _autoPingEnabled = false; bool _passiveModeEnabled = false; bool _hybridModeEnabled = false; - bool _nextPingIsDiscovery = true; // Start hybrid with discovery + bool _targetedModeEnabled = false; + bool _nextPingIsDiscovery = true; // Start hybrid with discovery Timer? _autoTimer; + // Targeted mode tracking + TraceTracker? _traceTracker; + StreamSubscription? _traceDataSubscription; + Timer? _targetedTimer; + String? _targetRepeaterId; + Position? _lastTargetedPosition; + // Pending disable flag - when true, disable will execute after RX window ends bool _pendingDisable = false; + // Last-resort backstop for the flag above. Every path that ends a ping + // lifecycle (RX/discovery/trace window end, validation skip, failed send) + // drains the flag itself (#496); this timer only remains for paths nobody + // has thought of, so a stranded disable can never outlive it (#476). + Timer? _pendingDisableTimeout; + + /// How long to wait for the real window completion before forcing a parked + /// disable. Comfortably past the longest legitimate in-flight ping (the 7s + /// discovery window) so a TX that is already on the air still gets its echoes. + static const Duration _pendingDisableTimeoutDelay = Duration(seconds: 12); + // Auto-ping interval in milliseconds (default 30s, options: 15s, 30s, 60s) // Reference: getSelectedIntervalMs() in wardrive.js int _autoPingIntervalMs = 30000; @@ -99,7 +146,8 @@ class PingService { StreamSubscription? _controlDataSubscription; Timer? _discoveryTimer; Position? _discoveryStartPosition; - Position? _lastDiscoveryPosition; // Track last discovery position for 25m check + Position? + _lastDiscoveryPosition; // Track last discovery position for 25m check // Validation callbacks bool Function()? checkExternalAntennaConfigured; @@ -108,36 +156,68 @@ class PingService { /// Callback to get the external antenna value for API payloads bool Function()? getExternalAntenna; + /// Callback to get the power level in watts (0.3, 0.6, 1.0, 2.0) from user preferences + double Function()? getPowerLevel; + /// Callback to check if discovery drop is enabled (failed discoveries → API) bool Function()? getDiscDropEnabled; /// Callback to check if TX is allowed by API (zone capacity check) bool Function()? checkTxAllowed; + /// Wire-tag composition (default privacy mode). Return the active session_id, + /// the wire-tag key from /auth, the next per-session ping counter, and whether + /// the user opted into broadcasting real coords on the air. + String? Function()? getSessionId; + String? Function()? getWireKey; + int Function()? getNextPingCounter; + bool Function()? getBroadcastCoords; + + /// Peek the current per-session ping counter, and react when it is exhausted + /// (wire tag's 11-bit cap) — AppStateProvider disconnects with a session-limit message. + int Function()? getPingCounter; + Future Function()? onSessionLimitReached; + /// Callback for ping events void Function(TxPing)? onTxPing; void Function(RxPing)? onRxPing; void Function(PingStats)? onStatsUpdated; - /// Called in real-time when each echo is received during tracking window + + /// Called in real-time when each direct echo is received during tracking window /// Parameters: (TxPing txPing, HeardRepeater repeater, bool isNew) void Function(TxPing, HeardRepeater, bool isNew)? onEchoReceived; + /// Called in real-time when each multi-hop echo is received during tracking window + void Function(TxPing txPing, String repeaterId, double? snr, int? rssi, + List pathHops, bool isNew)? onMultiHopEchoReceived; + /// Callback for discovery events (Passive Mode) /// Fires immediately when disc ping is created (like onTxPing) void Function(DiscLogEntry)? onDiscPing; /// Called in real-time when each node is discovered during tracking window /// Parameters: (DiscLogEntry discPing, DiscoveredNodeEntry nodeEntry, bool isNew) - void Function(DiscLogEntry, DiscoveredNodeEntry, bool isNew)? onDiscNodeDiscovered; + void Function(DiscLogEntry, DiscoveredNodeEntry, bool isNew)? + onDiscNodeDiscovered; /// Callback when TX window ends (for noise floor graph) - /// Parameters: (bool success) - true if any repeaters heard, false if none - void Function(bool success)? onTxWindowComplete; + /// Parameters: (bool directSuccess, List multiHopEchoes) + void Function( + bool directSuccess, + List<({String repeaterId, double? snr, int? rssi, List pathHops})> + multiHopEchoes)? onTxWindowComplete; /// Callback when discovery window ends (for noise floor graph) /// Parameters: (bool success) - true if any nodes discovered, false if none void Function(bool success)? onDiscoveryWindowComplete; + /// Callback when trace window ends (for noise floor graph + log) + /// Parameters: (TraceResult? result) - null if no response + void Function(TraceResult? result)? onTraceWindowComplete; + + /// Callback when trace ping is sent (for log entry creation) + void Function(TraceLogEntry)? onTracePing; + /// Callback when pingInProgress changes (for immediate UI refresh) void Function()? onPingProgressChanged; @@ -166,6 +246,8 @@ class PingService { AudioService? audioService, this.shouldIgnoreRepeater, this.disableRssiFilter = false, + int hopBytes = 1, + int traceHopBytes = 1, }) : _gpsService = gpsService, _connection = connection, _apiQueue = apiQueue, @@ -176,7 +258,9 @@ class PingService { _discoveryWindowCountdown = discoveryWindowTimer, _deviceId = deviceId, _txTracker = txTracker, - _audioService = audioService; + _audioService = audioService, + _hopBytes = hopBytes, + _traceHopBytes = traceHopBytes; /// Get current ping statistics PingStats get stats => _stats; @@ -193,6 +277,9 @@ class PingService { /// Check if Hybrid Mode is active (alternates discovery + TX) bool get isHybridMode => _hybridModeEnabled; + /// Check if Targeted Mode is active (zero-hop trace to specific repeater) + bool get isTargetedMode => _targetedModeEnabled; + /// Check if discovery tracker is currently listening (for Passive Mode UI) bool get isDiscoveryListening => _discTracker?.isListening ?? false; @@ -206,7 +293,8 @@ class PingService { String? get skipReason => _skipReason; /// Get the manual ping cooldown timer (for UI display) - ManualPingCooldownTimer get manualPingCooldownTimer => _manualPingCooldownTimer; + ManualPingCooldownTimer get manualPingCooldownTimer => + _manualPingCooldownTimer; /// Set auto-ping interval (15000, 30000, or 60000 ms) /// Reference: getSelectedIntervalMs() in wardrive.js @@ -260,8 +348,12 @@ class PingService { // If user hasn't moved, old position is still valid // Check GPS accuracy (< 100m) + // Deliberately silent, like the other two validators. This runs on every + // provider notify (ping_controls.dart reads it to enable the buttons), so + // logging here wrote a line per second for as long as GPS stayed poor: 196 + // lines in four minutes, 20% of a reporter's whole debug file (#476). The + // send paths log the blocking reason instead, once per real attempt. if (!_gpsService.isAccuracyAcceptableForPing(position)) { - debugWarn('[PING] GPS accuracy too low, rejecting ping'); return PingValidation.gpsInaccurate; } @@ -411,6 +503,11 @@ class PingService { return remaining.inSeconds.clamp(0, _autoPingCooldown.inSeconds); } + /// Clear the auto-ping cooldown (used during zone transfer to avoid blocking restart). + void clearCooldown() { + _lastTxTime = null; + } + /// Check if currently in manual ping cooldown period bool isInManualCooldown() { return _manualPingCooldownTimer.remainingMs > 0; @@ -421,12 +518,48 @@ class PingService { return _manualPingCooldownTimer.remainingSec; } + /// Set when a deadline gate refused a transmission. + /// + /// Lets a caller tell "the radio never keyed up because I had already given + /// up" apart from the ordinary reasons a send is skipped: cooldown, failed + /// validation, no GPS. Only the former abandons a start or earns a spoken + /// "took too long"; the rest keep their own reasons. Reset on entry to every + /// gated path, so it always describes the send just attempted. + bool _transmitAbortedByDeadline = false; + + bool get transmitAbortedByDeadline => _transmitAbortedByDeadline; + + /// Whether the surface waiting on this transmission has already given up. + /// + /// Call at the last suspension point before an RF send. Every send path here + /// awaits a fresh GPS fix first, and that fix can outlive the deadline an + /// external surface is holding a person on. + bool _deadlinePassed(bool Function()? shouldAbortBeforeTransmit) { + if (!(shouldAbortBeforeTransmit?.call() ?? false)) return false; + _transmitAbortedByDeadline = true; + debugLog('[PING] Deadline passed before transmit, not sending'); + return true; + } + /// Send a TX ping /// @param manual - Whether this is a manual ping (true) or auto ping (false) + /// @param shouldAbortBeforeTransmit - Deadline gate for an external caller /// Returns true if ping was sent successfully /// Reference: sendPing() in wardrive.js - Future sendTxPing({bool manual = true}) async { + Future sendTxPing({ + bool manual = true, + bool Function()? shouldAbortBeforeTransmit, + }) async { debugLog('[PING] sendTxPing called (manual=$manual)'); + _transmitAbortedByDeadline = false; + + // Guard: don't send pings if connection is not in connected state + // Handles race where timer callback fires after reconnect started + if (_connection.currentStep != ConnectionStep.connected) { + debugLog( + '[PING] Ignoring TX ping — not connected (step: ${_connection.currentStep})'); + return false; + } // Early guard: prevent concurrent ping execution (critical for preventing BLE GATT errors) // Reference: state.pingInProgress check in wardrive.js @@ -437,12 +570,38 @@ class PingService { _pingInProgress = true; try { + // For auto pings, request a fresh GPS position before validation. + // This ensures the 25m distance check and ping coordinates reflect + // where the device is NOW, not where it was at the last stream event. + if (!manual) { + await _gpsService.getFreshPosition(); + } + + // The transport parks a non-sign write behind an in-progress sign, and + // that wait is unbounded. It would happen inside the send call below, + // after the TxPing record exists and past everything checkable here. Take + // it now, while abandoning still costs nothing. + if (shouldAbortBeforeTransmit != null) { + await _connection.awaitWritableState(); + } + + // Last suspension point before the transmission: with the write gate + // already open, nothing below this awaits until the BLE send. Checking + // here rather than at the send itself is the same instant in wall-clock + // terms and leaves no TxPing record and no consumed wire-tag counter + // behind for a ping that never went out. + if (_deadlinePassed(shouldAbortBeforeTransmit)) { + _pingInProgress = false; + return false; + } + // Use different validation and cooldown for manual vs auto pings if (manual) { // Manual ping: 15-second cooldown, no distance check if (isInManualCooldown()) { final remainingSec = getRemainingManualCooldownSeconds(); - debugLog('[PING] Manual ping blocked by cooldown (${remainingSec}s remaining)'); + debugLog( + '[PING] Manual ping blocked by cooldown (${remainingSec}s remaining)'); _pingInProgress = false; return false; } @@ -459,22 +618,33 @@ class PingService { // could still trigger an auto-ping from a late RX window timer callback if (isInCooldown()) { final remainingSec = getRemainingCooldownSeconds(); - debugLog('[PING] Auto ping blocked by cooldown (${remainingSec}s remaining)'); + debugLog( + '[PING] Auto ping blocked by cooldown (${remainingSec}s remaining)'); _pingInProgress = false; return false; } final validation = canPing(); if (validation != PingValidation.valid) { + // Unlock BEFORE scheduling. onAutoPingScheduled fires synchronously + // and the idle auto-stop hangs off it, so a disable arriving while + // this is still true latches as pending and never drains (a skipped + // ping arms no RX window). Matches _sendDiscoveryRequest's ordering. + _pingInProgress = false; + // Logged here rather than inside canPing(), which the UI calls on + // every notify. Once per real attempt, matching the manual path. + debugLog('[PING] Auto ping blocked by validation: $validation'); // For auto mode, schedule next attempt if distance check failed if (_autoPingEnabled && !_passiveModeEnabled) { if (validation == PingValidation.tooCloseToLastPing) { _skipReason = 'too close'; - debugLog('[PING] Auto ping blocked: too close to last ping, scheduling next'); } - _scheduleNextAutoPing(); + if (_hybridModeEnabled) { + _scheduleNextHybridPing(); + } else { + _scheduleNextAutoPing(); + } } - _pingInProgress = false; return false; } } @@ -488,15 +658,50 @@ class PingService { _pingInProgress = false; return false; } - // Use power in watts (0.3, 0.6, 1.0, 2.0) - matches web client buildPayload() - final powerWatts = _connection.deviceModel?.power ?? 0.3; - // Also get txPower in dBm for API queue (for database records) final txPowerDbm = _connection.deviceModel?.txPower ?? 22; - // Build ping message (same format used for TxTracker correlation) - final coordsStr = '${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'; - final powerStr = '${powerWatts.toStringAsFixed(1)}w'; - final pingMessage = '@[MapperBot] $coordsStr [$powerStr]'; + // Build the on-air body ONCE (same string is used for TxTracker echo + // correlation AND the actual transmission). Power is sent per-ping in the API. + // + // A keyed wire tag "MM:" (privacy default), or "MM::lat,lon" when + // Broadcast My Coordinates is on (tag + plaintext coords). Those are the only + // two shapes: without a session there is no tag, and the ping is refused. + final coordsStr = + '${position.latitude.toStringAsFixed(5)},${position.longitude.toStringAsFixed(5)}'; + final broadcastCoords = getBroadcastCoords?.call() ?? false; + final sessionId = getSessionId?.call(); + String pingMessage; + int? txPingCounter; + String? txWireTag; + final hasSession = sessionId != null && sessionId.isNotEmpty; + if (hasSession) { + // Session-limit guard: the wire tag's counter is 11 bits (max 2047). When it + // is exhausted, end the session cleanly rather than repeating/corrupting tags. + // Applies to BOTH privacy and broadcast-coords modes — a combined ping consumes + // a counter exactly like a privacy ping, so the session ends identically. + if ((getPingCounter?.call() ?? 0) >= 2047) { + debugError( + '[SESSION] Reached session ping limit (2047) — disconnecting'); + _pingInProgress = false; + onSessionLimitReached?.call(); + return false; + } + txPingCounter = getNextPingCounter?.call() ?? 1; + txWireTag = + WireTagCodec.encode(sessionId, txPingCounter, getWireKey?.call()); + // Identical to privacy mode in every way EXCEPT broadcast-coords appends the + // plaintext coords to the on-air body. The bare tag still goes to the API + // (txWireTag → _pendingTxWireTag), so /wardrive validation + tx_pings are unchanged. + pingMessage = broadcastCoords ? '$txWireTag:$coordsStr' : txWireTag; + } else { + // Unreachable: tx_allowed and session_id arrive in the same /auth response, + // and every TX validator requires txAllowed. If we get here the session state + // is corrupt, so refuse to transmit rather than emit a tagless (untraceable) + // ping. Every ping the app sends carries a wire tag by construction. + debugError('[PING] TX attempted with no session, aborting ping'); + _pingInProgress = false; + return false; + } // Capture noise floor at ping time final noiseFloor = _connection.lastNoiseFloor; @@ -525,13 +730,17 @@ class PingService { final channelHash = _connection.wardrivingChannelHash; final channelKey = _connection.wardrivingChannelKey; - if (_txTracker != null && channelIndex != null && channelHash != null && channelKey != null) { + if (_txTracker != null && + channelIndex != null && + channelHash != null && + channelKey != null) { debugLog('[PING] Starting TX echo tracking for: "$pingMessage"'); // Wire up real-time echo callback before starting tracking final txTracker = _txTracker; txTracker.onEchoReceived = (repeaterId, snr, rssi, isNew) { - debugLog('[PING] onEchoReceived callback fired: $repeaterId, SNR=$snr, RSSI=$rssi, isNew=$isNew'); + debugLog( + '[PING] onEchoReceived callback fired: $repeaterId, SNR=$snr, RSSI=$rssi, isNew=$isNew'); final txPing = _lastTxPing; if (txPing != null) { final repeater = HeardRepeater( @@ -544,18 +753,22 @@ class PingService { if (isNew) { // Add new repeater to the list txPing.heardRepeaters.add(repeater); - debugLog('[PING] Real-time: Added new repeater $repeaterId (SNR: $snr) - total: ${txPing.heardRepeaters.length}'); + debugLog( + '[PING] Real-time: Added new repeater $repeaterId (SNR: $snr) - total: ${txPing.heardRepeaters.length}'); } else { // Update existing repeater's SNR if better - final idx = txPing.heardRepeaters.indexWhere((r) => r.repeaterId == repeaterId); + final idx = txPing.heardRepeaters + .indexWhere((r) => r.repeaterId == repeaterId); if (idx >= 0) { txPing.heardRepeaters[idx] = repeater; - debugLog('[PING] Real-time: Updated repeater $repeaterId (SNR: $snr)'); + debugLog( + '[PING] Real-time: Updated repeater $repeaterId (SNR: $snr)'); } } // Notify for real-time UI updates - debugLog('[PING] Calling onEchoReceived callback (callback=${onEchoReceived != null ? "SET" : "NULL"})'); + debugLog( + '[PING] Calling onEchoReceived callback (callback=${onEchoReceived != null ? "SET" : "NULL"})'); onEchoReceived?.call(txPing, repeater, isNew); debugLog('[PING] onEchoReceived callback completed'); } else { @@ -563,6 +776,36 @@ class PingService { } }; + txTracker.onMultiHopEchoReceived = + (repeaterId, snr, rssi, pathHops, isNew) { + debugLog( + '[PING] Multi-hop echo: $repeaterId, SNR=$snr, hops=${pathHops.length}, isNew=$isNew'); + final txPing = _lastTxPing; + if (txPing != null) { + final repeater = HeardRepeater( + repeaterId: repeaterId, + snr: snr, + rssi: rssi, + seenCount: + txTracker.multiHopRepeaters[repeaterId]?.seenCount ?? 1, + pathHops: pathHops, + ); + + if (isNew) { + txPing.heardRepeaters.add(repeater); + } else { + final idx = txPing.heardRepeaters.indexWhere( + (r) => r.repeaterId == repeaterId && r.pathHops != null); + if (idx >= 0) { + txPing.heardRepeaters[idx] = repeater; + } + } + + onMultiHopEchoReceived?.call( + txPing, repeaterId, snr, rssi, pathHops, isNew); + } + }; + txTracker.startTracking( payload: pingMessage, channelIdx: channelIndex, @@ -571,14 +814,15 @@ class PingService { windowDuration: _rxListeningWindow, ); } else { - debugWarn('[PING] TX tracking not available - channel info missing or no tracker'); + debugWarn( + '[PING] TX tracking not available - channel info missing or no tracker'); } // Play transmit sound immediately before sending _audioService?.playTransmitSound(); - // Send ping via BLE - uses watts format like "1.0w" - await _connection.sendPing(position.latitude, position.longitude, powerWatts); + // Send ping via BLE (pre-composed body — wire tag or legacy coords) + await _connection.sendPing(pingMessage); // Mark ping time and position _lastTxTime = DateTime.now(); @@ -597,6 +841,8 @@ class PingService { // TX entry is queued AFTER RX window so heard_repeats can be populated _pendingTxTimestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; _pendingTxNoiseFloor = noiseFloor; + _pendingTxPingCounter = txPingCounter; // null in coords mode + _pendingTxWireTag = txWireTag; // null in coords mode // Start RX listening window (TX will be queued when window ends) _startRxListeningWindow(position); @@ -614,6 +860,16 @@ class PingService { debugLog('[PING] Ping operation failed: $e'); _pingInProgress = false; return false; + } finally { + // A ping that ended without arming an RX window (validation skip, no + // GPS, failed send, any early return above) is the end of the lifecycle + // a queued disable was waiting on. Drain it here rather than leaving it + // for the timeout backstop, which locked the controls for the whole + // 12s wait (#496). A successful send leaves _pingInProgress true until + // _endRxListeningWindow, which does its own drain. + if (!_pingInProgress && _pendingDisable) { + await _executePendingDisable('ping ended without RX window'); + } } } @@ -645,7 +901,8 @@ class PingService { final txTracker = _txTracker; final txSuccess = txTracker != null && txTracker.repeaters.isNotEmpty; if (txSuccess) { - debugLog('[PING] TxTracker collected ${txTracker.repeaters.length} repeater echoes'); + debugLog( + '[PING] TxTracker collected ${txTracker.repeaters.length} repeater echoes'); // Format heard_repeats: "repeaterId(snr),repeaterId(snr)" // Reference: buildHeardRepeatsString() in wardrive.js @@ -662,17 +919,36 @@ class PingService { heardRepeats = repeaterStrings.join(','); // Update RX count stat for the echoes heard - _stats = _stats.copyWith(rxCount: _stats.rxCount + txTracker.repeaters.length); + _stats = + _stats.copyWith(rxCount: _stats.rxCount + txTracker.repeaters.length); onStatsUpdated?.call(_stats); } else { debugLog('[PING] No repeater echoes detected during listening window'); } + // Collect multi-hop echo data for the onTxWindowComplete callback + final multiHopEchoes = <({ + String repeaterId, + double? snr, + int? rssi, + List pathHops + })>[]; + if (txTracker != null && txTracker.multiHopRepeaters.isNotEmpty) { + for (final entry in txTracker.multiHopRepeaters.entries) { + final echo = entry.value; + multiHopEchoes.add(( + repeaterId: echo.repeaterId, + snr: echo.snr, + rssi: echo.rssi, + pathHops: echo.pathHops, + )); + } + } + // Notify about TX window completion for noise floor graph - onTxWindowComplete?.call(txSuccess); + onTxWindowComplete?.call(txSuccess, multiHopEchoes); // Queue TX entry with heard_repeats AFTER RX window ends - // Reference: enqueueTX() called after RX window in wardrive.js final txTimestamp = _pendingTxTimestamp; if (txTimestamp != null) { _apiQueue.enqueueTx( @@ -682,9 +958,34 @@ class PingService { timestamp: txTimestamp, externalAntenna: getExternalAntenna?.call() ?? false, noiseFloor: _pendingTxNoiseFloor, + power: getPowerLevel?.call(), + pingCounter: + _pendingTxPingCounter, // null in coords mode → server coords path + wireTag: _pendingTxWireTag, // null in coords mode → server coords path ); debugLog('[PING] Queued TX entry with heard_repeats: $heardRepeats'); + // Queue multi-hop echoes as individual RX API entries + if (multiHopEchoes.isNotEmpty) { + for (final echo in multiHopEchoes) { + final rxHeardRepeats = echo.snr != null + ? '${echo.repeaterId}(${echo.snr!.toStringAsFixed(2)})' + : '${echo.repeaterId}(null)'; + _apiQueue.enqueueRx( + latitude: txPosition.latitude, + longitude: txPosition.longitude, + heardRepeats: rxHeardRepeats, + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + repeaterId: echo.repeaterId, + externalAntenna: getExternalAntenna?.call() ?? false, + noiseFloor: _pendingTxNoiseFloor, + power: getPowerLevel?.call(), + ); + } + debugLog( + '[PING] Queued ${multiHopEchoes.length} multi-hop echoes as RX'); + } + // Clear pending TX context _pendingTxTimestamp = null; _pendingTxNoiseFloor = null; @@ -696,25 +997,8 @@ class PingService { // After RX window ends, check if disable was requested during the window if (_pendingDisable) { - debugLog('[PING] Executing pending disable after RX window'); - _pendingDisable = false; - final wasHybrid = _hybridModeEnabled; - _autoPingEnabled = false; - _passiveModeEnabled = false; - _hybridModeEnabled = false; - _nextPingIsDiscovery = true; - _autoTimer?.cancel(); - _autoTimer = null; - // Clean up discovery infrastructure if hybrid was enabled - if (wasHybrid) { - _stopDiscoveryMode(); - } - // Start cooldown immediately - _cooldownTimer.start(_autoPingCooldown.inMilliseconds); - debugLog('[PING] Pending disable complete, cooldown started'); - // Notify AppStateProvider to update its state and cleanup - await onPendingDisableComplete?.call(); - return; // Don't schedule next auto ping + await _executePendingDisable('after RX window'); + return; // Don't schedule next auto ping } // Schedule next ping based on mode @@ -723,10 +1007,12 @@ class PingService { // Reference: scheduleNextAutoPing() called after RX window in wardrive.js if (_autoPingEnabled && !isInCooldown()) { if (_hybridModeEnabled) { - debugLog('[HYBRID] Scheduling next hybrid ping after RX window completion'); + debugLog( + '[HYBRID] Scheduling next hybrid ping after RX window completion'); _scheduleNextHybridPing(); } else if (!_passiveModeEnabled) { - debugLog('[ACTIVE MODE] Scheduling next auto ping after RX window completion'); + debugLog( + '[ACTIVE MODE] Scheduling next auto ping after RX window completion'); _scheduleNextAutoPing(); } } else if (isInCooldown()) { @@ -740,7 +1026,8 @@ class PingService { /// Reference: scheduleNextAutoPing() in wardrive.js void _scheduleNextAutoPing() { if (!_autoPingEnabled || _passiveModeEnabled) { - debugLog('[ACTIVE MODE] Not scheduling next auto ping - auto mode not running or Passive Mode'); + debugLog( + '[ACTIVE MODE] Not scheduling next auto ping - auto mode not running or Passive Mode'); return; } @@ -749,16 +1036,31 @@ class PingService { _autoTimer?.cancel(); _autoTimer = null; - debugLog('[ACTIVE MODE] Scheduling next auto ping in ${_autoPingIntervalMs}ms'); + debugLog( + '[ACTIVE MODE] Scheduling next auto ping in ${_autoPingIntervalMs}ms'); // Start countdown display (with skip reason if applicable) // The AutoPingTimer in countdown_timer_service.dart handles the display onAutoPingScheduled?.call(_autoPingIntervalMs, _skipReason); + // That callback runs synchronously and can stop auto mode (the idle + // auto-stop hangs off it), which cancels _autoTimer. Re-check so we don't + // re-arm a timer the stop just cleared. + if (!_autoPingEnabled || _passiveModeEnabled) { + debugLog( + '[ACTIVE MODE] Auto mode stopped while scheduling, not arming timer'); + return; + } + // Schedule the next ping _autoTimer = Timer(Duration(milliseconds: _autoPingIntervalMs), () { debugLog('[ACTIVE MODE] Auto ping timer fired'); + // Guard: connection may have dropped since timer was scheduled + if (_connection.currentStep != ConnectionStep.connected) { + debugLog('[ACTIVE MODE] Not connected, ignoring timer'); + return; + } // Double-check guards before sending ping if (!_autoPingEnabled || _passiveModeEnabled) { debugLog('[ACTIVE MODE] Auto mode no longer running, ignoring timer'); @@ -791,9 +1093,14 @@ class PingService { } /// Helper to send initial auto ping with error handling - Future _sendInitialAutoPing() async { + Future _sendInitialAutoPing({ + bool Function()? shouldAbortBeforeTransmit, + }) async { try { - await sendTxPing(manual: false); + await sendTxPing( + manual: false, + shouldAbortBeforeTransmit: shouldAbortBeforeTransmit, + ); } catch (e) { debugLog('[ACTIVE MODE] Initial auto ping error: $e'); // Even on error, schedule next ping @@ -801,20 +1108,43 @@ class PingService { } } - /// Enable Active Mode (timer-based auto ping), Passive Mode (listen-only), or Hybrid Mode + /// Enable Active Mode (timer-based auto ping), Passive Mode (listen-only), + /// Hybrid Mode, or Targeted Mode (zero-hop trace) /// Reference: startAutoPing() in wardrive.js /// @param passiveMode - If true, only listens for RX (no TX pings) - this is Passive Mode /// @param hybridMode - If true, alternates discovery + TX pings each interval - Future enableAutoPing({bool passiveMode = false, bool hybridMode = false}) async { - debugLog('[AUTO] enableAutoPing called (passiveMode=$passiveMode, hybridMode=$hybridMode)'); + /// @param targetedMode - If true, sends trace path to specific repeater + /// @param targetRepeaterId - Repeater ID hex string (required when targetedMode=true) + /// @param shouldAbortBeforeTransmit - Deadline gate for an external caller + /// + /// The gate lets an external surface holding a person on a deadline (Siri) + /// abandon the start if that deadline passes before the session's first + /// transmission. A session must never come up after the surface has already + /// reported that it did not. + Future enableAutoPing({ + bool passiveMode = false, + bool hybridMode = false, + bool targetedMode = false, + String? targetRepeaterId, + bool Function()? shouldAbortBeforeTransmit, + }) async { + debugLog( + '[AUTO] enableAutoPing called (passiveMode=$passiveMode, hybridMode=$hybridMode, targetedMode=$targetedMode)'); if (_autoPingEnabled) { debugLog('[AUTO] Auto mode already enabled'); return false; } + // Targeted mode requires a repeater ID + if (targetedMode && + (targetRepeaterId == null || targetRepeaterId.isEmpty)) { + debugLog('[AUTO] Targeted mode requires a repeater ID'); + return false; + } + // Check if we're in cooldown (can't start during cooldown) - // Hybrid and Active modes are blocked by cooldown, Passive is not + // Hybrid, Active, and Targeted modes are blocked by cooldown, Passive is not // Reference: isInCooldown() check in startAutoPing() in wardrive.js if (!passiveMode && isInCooldown()) { final remainingSec = getRemainingCooldownSeconds(); @@ -828,37 +1158,94 @@ class PingService { // Clear any previous skip reason _skipReason = null; + _transmitAbortedByDeadline = false; _autoPingEnabled = true; _passiveModeEnabled = passiveMode; _hybridModeEnabled = hybridMode; - _nextPingIsDiscovery = true; // Always start hybrid with discovery + _targetedModeEnabled = targetedMode; + _nextPingIsDiscovery = true; // Always start hybrid with discovery + + if (targetedMode) { + _targetRepeaterId = targetRepeaterId; + } // Enable wake lock to keep screen on during auto mode // Reference: acquireWakeLock() in wardrive.js debugLog('[AUTO] Acquiring wake lock for auto mode'); await _wakelockService.enable(); - if (hybridMode) { + if (targetedMode) { + // Targeted Mode: send trace path to specific repeater + debugLog( + '[TARGETED] Targeted Mode started - tracing repeater $targetRepeaterId'); + await _startTargetedMode(); + } else if (hybridMode) { // Hybrid Mode: set up discovery infrastructure, then start with discovery - debugLog('[HYBRID] Hybrid Mode started - alternating discovery + TX pings'); - await _startDiscoveryMode(); + debugLog( + '[HYBRID] Hybrid Mode started - alternating discovery + TX pings'); + await _startDiscoveryMode( + shouldAbortBeforeTransmit: shouldAbortBeforeTransmit, + ); // First ping was discovery, so next should be TX _nextPingIsDiscovery = false; } else if (passiveMode) { // Passive Mode: send discovery requests instead of TX pings - debugLog('[PASSIVE MODE] Passive Mode started - using discovery protocol'); - await _startDiscoveryMode(); + debugLog( + '[PASSIVE MODE] Passive Mode started - using discovery protocol'); + await _startDiscoveryMode( + shouldAbortBeforeTransmit: shouldAbortBeforeTransmit, + ); } else { // Active Mode: send first ping immediately, then schedule timer // Reference: sendPing(false) called immediately in startAutoPing() in wardrive.js debugLog('[ACTIVE MODE] Sending initial auto ping'); - _sendInitialAutoPing(); + if (shouldAbortBeforeTransmit == null) { + _sendInitialAutoPing(); + } else { + // Awaited only on the deadline-carrying path, so the ordinary start + // keeps returning as soon as the first ping is on its way. + await _sendInitialAutoPing( + shouldAbortBeforeTransmit: shouldAbortBeforeTransmit, + ); + } + } + + if (_transmitAbortedByDeadline) { + await _abandonAutoPingStart(); + return false; } return true; } + /// Unwind a start whose deadline passed before the first transmission. + /// + /// Mirrors the teardown in [disableAutoPing] without its cooldown and + /// pending-disable handling: nothing was transmitted, so there is no RX + /// window to let finish and no cooldown to earn. + Future _abandonAutoPingStart() async { + debugLog('[AUTO] Abandoning start: caller gave up before first transmit'); + _autoTimer?.cancel(); + _autoTimer = null; + _skipReason = null; + + if (_passiveModeEnabled || _hybridModeEnabled) { + _stopDiscoveryMode(); + } + if (_targetedModeEnabled) { + _stopTargetedMode(); + } + + _autoPingEnabled = false; + _passiveModeEnabled = false; + _hybridModeEnabled = false; + _targetedModeEnabled = false; + _nextPingIsDiscovery = true; + + await _wakelockService.disable(); + } + /// Disable auto-ping mode (Active Mode or Passive Mode) /// Reference: stopAutoPing() and stopRxAuto() in wardrive.js Future disableAutoPing() async { @@ -874,14 +1261,16 @@ class PingService { if (_pingInProgress) { debugLog('[PING] Ping in progress, queuing disable for after RX window'); _pendingDisable = true; - return true; // Return true to indicate disable was accepted (pending) + _armPendingDisableTimeout(); + return true; // Return true to indicate disable was accepted (pending) } // Check cooldown before stopping (unless forced) // Reference: isInCooldown() check in stopAutoPing() in wardrive.js if (!_passiveModeEnabled && isInCooldown()) { final remainingSec = getRemainingCooldownSeconds(); - debugLog('[ACTIVE MODE] Stop blocked by cooldown (${remainingSec}s remaining)'); + debugLog( + '[ACTIVE MODE] Stop blocked by cooldown (${remainingSec}s remaining)'); return false; } @@ -897,9 +1286,15 @@ class PingService { _stopDiscoveryMode(); } + // Clean up targeted mode infrastructure + if (_targetedModeEnabled) { + _stopTargetedMode(); + } + _autoPingEnabled = false; _passiveModeEnabled = false; _hybridModeEnabled = false; + _targetedModeEnabled = false; _nextPingIsDiscovery = true; // Disable wake lock when auto mode stops @@ -910,18 +1305,93 @@ class PingService { return true; } + /// Run the disable that [disableAutoPing] parked while a ping was in flight: + /// clear auto mode, cancel the auto timer, start the cooldown, then hand back + /// to AppStateProvider for its half of the teardown. + /// + /// Safe to call from either the RX window completion or the timeout backstop: + /// it clears [_pendingDisable] and the timeout first, and both callers gate on + /// [_pendingDisable] still being set, so it can never run twice. + Future _executePendingDisable(String trigger) async { + debugLog('[PING] Executing pending disable ($trigger)'); + _pendingDisable = false; + _pendingDisableTimeout?.cancel(); + _pendingDisableTimeout = null; + final wasPassive = _passiveModeEnabled; + final wasHybrid = _hybridModeEnabled; + final wasTargeted = _targetedModeEnabled; + _autoPingEnabled = false; + _passiveModeEnabled = false; + _hybridModeEnabled = false; + _targetedModeEnabled = false; + _nextPingIsDiscovery = true; + _autoTimer?.cancel(); + _autoTimer = null; + // Clean up discovery infrastructure if passive or hybrid was enabled + if (wasPassive || wasHybrid) { + _stopDiscoveryMode(); + } + // Clean up targeted infrastructure if targeted was enabled + if (wasTargeted) { + _stopTargetedMode(); + } + // Start cooldown immediately + _cooldownTimer.start(_autoPingCooldown.inMilliseconds); + debugLog('[PING] Pending disable complete, cooldown started'); + // Notify AppStateProvider to update its state and cleanup. Several callers + // run from void tracker callbacks or timer-driven send paths where nothing + // awaits this method, so an escaping error here would go unhandled. The + // local teardown above is already done by this point. + try { + await onPendingDisableComplete?.call(); + } catch (e) { + debugError('[PING] Pending disable provider cleanup failed: $e'); + } + } + + /// Guarantee a parked disable is drained even when the RX window that was + /// supposed to drain it never arrives. + /// + /// The window is only armed once a ping actually goes out. A ping rejected by + /// validation, or one that fails to send, clears [_pingInProgress] and returns + /// without arming anything, so the queued disable had no completion to wait + /// for and auto mode kept running (#476: hybrid kept scheduling pings for four + /// minutes after the user stopped it). + void _armPendingDisableTimeout() { + _pendingDisableTimeout?.cancel(); + _pendingDisableTimeout = Timer(_pendingDisableTimeoutDelay, () async { + if (!_pendingDisable) return; // the window drained it, nothing to do + debugWarn('[PING] Pending disable never drained after ' + '${_pendingDisableTimeoutDelay.inSeconds}s (no RX window arrived) - forcing it'); + // The ping this was waiting on is gone. Leaving this set would keep the + // ping controls locked out until a restart. + _pingInProgress = false; + try { + await _executePendingDisable('timeout backstop'); + } catch (e) { + // Nothing is awaiting a timer callback, so an escaping error here would + // go unhandled. The flags are already cleared by this point. + debugError('[PING] Forced pending disable failed: $e'); + } + }); + } + /// Force disable auto-ping (ignores cooldown, used for disconnect) Future forceDisableAutoPing() async { debugLog('[PING] Force disabling auto-ping'); - _pendingDisable = false; // Clear any pending disable + _pendingDisable = false; // Clear any pending disable + _pendingDisableTimeout?.cancel(); + _pendingDisableTimeout = null; _autoTimer?.cancel(); _autoTimer = null; _skipReason = null; _autoPingEnabled = false; _passiveModeEnabled = false; _hybridModeEnabled = false; + _targetedModeEnabled = false; _nextPingIsDiscovery = true; _stopDiscoveryMode(); + _stopTargetedMode(); await _wakelockService.disable(); } @@ -936,18 +1406,22 @@ class PingService { // ============================================ /// Start discovery mode - subscribes to control data and sends discovery requests - Future _startDiscoveryMode() async { + Future _startDiscoveryMode({ + bool Function()? shouldAbortBeforeTransmit, + }) async { debugLog('[DISC] Starting discovery mode'); // Create and configure discovery tracker final tracker = DiscTracker( shouldIgnoreRepeater: shouldIgnoreRepeater, disableRssiFilter: disableRssiFilter, + hopBytes: _hopBytes, ); _discTracker = tracker; tracker.onCarpeaterDrop = onDiscCarpeaterDrop; tracker.onNodeDiscovered = (node, isNew) { - debugLog('[DISC] Node discovered: ${node.repeaterId} (${node.nodeTypeName}), isNew=$isNew'); + debugLog( + '[DISC] Node discovered: ${node.repeaterId} (${node.nodeTypeName}), isNew=$isNew'); final discPing = _lastDiscPing; if (discPing != null) { final nodeEntry = DiscoveredNodeEntry( @@ -961,7 +1435,8 @@ class PingService { if (isNew) { discPing.discoveredNodes.add(nodeEntry); } else { - final idx = discPing.discoveredNodes.indexWhere((n) => n.repeaterId == node.repeaterId); + final idx = discPing.discoveredNodes + .indexWhere((n) => n.repeaterId == node.repeaterId); if (idx >= 0) discPing.discoveredNodes[idx] = nodeEntry; } onDiscNodeDiscovered?.call(discPing, nodeEntry, isNew); @@ -981,7 +1456,9 @@ class PingService { }); // Send first discovery request immediately - await _sendDiscoveryRequest(); + await _sendDiscoveryRequest( + shouldAbortBeforeTransmit: shouldAbortBeforeTransmit, + ); } /// Stop discovery mode - cleans up tracker and subscription @@ -994,19 +1471,48 @@ class PingService { _discTracker?.dispose(); _discTracker = null; _discoveryStartPosition = null; - _lastDiscoveryPosition = null; // Reset so first discovery always sends on next start + _lastDiscoveryPosition = + null; // Reset so first discovery always sends on next start _lastDiscPing = null; } /// Send a discovery request and start listening window - Future _sendDiscoveryRequest() async { + /// + /// [shouldAbortBeforeTransmit] is supplied only for the session's first + /// discovery. Later requests come from timers and belong to the session + /// itself, so no external deadline applies to them. + Future _sendDiscoveryRequest({ + bool Function()? shouldAbortBeforeTransmit, + }) async { + // Guard: don't send discovery during reconnect (race with timer queue) + if (_connection.currentStep != ConnectionStep.connected) { + debugLog( + '[DISC] Ignoring discovery request — not connected (step: ${_connection.currentStep})'); + return; + } + if (!_autoPingEnabled || (!_passiveModeEnabled && !_hybridModeEnabled)) { debugLog('[DISC] Not in Passive/Hybrid Mode, skipping discovery request'); return; } - // Check GPS - final position = _gpsService.lastPosition; + // Request fresh GPS position before discovery (same rationale as TX auto-ping) + final position = await _gpsService.getFreshPosition(); + + // As in sendTxPing: the transport parks a non-sign write behind an + // in-progress sign, so take that wait here rather than inside the send. + if (shouldAbortBeforeTransmit != null) { + await _connection.awaitWritableState(); + } + + // Ahead of every early return below, not just of the send: those returns + // schedule a retry, so a caller that has already given up would otherwise + // get a session that transmits on the next tick anyway. + if (_deadlinePassed(shouldAbortBeforeTransmit)) { + _pingInProgress = false; + return; + } + if (position == null) { debugLog('[DISC] No GPS position, skipping discovery request'); _pingInProgress = false; @@ -1023,8 +1529,9 @@ class PingService { position.latitude, position.longitude, ); - if (distance < GpsService.minDistanceMeters) { - debugLog('[DISC] Too close to last discovery (${distance.toStringAsFixed(1)}m < 25m), skipping'); + if (distance < _gpsService.configuredMinDistance) { + debugLog( + '[DISC] Too close to last discovery (${distance.toStringAsFixed(1)}m < ${_gpsService.configuredMinDistance.toInt()}m), skipping'); _skipReason = 'too close'; _pingInProgress = false; _scheduleNextDiscovery(); @@ -1060,7 +1567,8 @@ class PingService { debugLog('[DISC] Created DiscLogEntry, ready for node tracking'); onDiscPing?.call(discPing); - debugLog('[DISC] Sending discovery request at ${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'); + debugLog( + '[DISC] Sending discovery request at ${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'); try { // Play transmit sound immediately before sending @@ -1084,15 +1592,22 @@ class PingService { // Update last discovery position for 25m check _lastDiscoveryPosition = position; + // Follow with the display anchor so the map's distance readout tracks + // discovery pings too, not just TX (#501) + _gpsService.markActivityPosition(position); } catch (e) { _pingInProgress = false; debugError('[DISC] Failed to send discovery request: $e'); + if (_pendingDisable) { + await _executePendingDisable('discovery send failed'); + return; + } _scheduleNextDiscovery(); } } /// Handle discovery window completion - void _handleDiscoveryWindowComplete(List nodes) { + Future _handleDiscoveryWindowComplete(List nodes) async { _discoveryWindowCountdown.stop(); final position = _discoveryStartPosition; if (position == null) { @@ -1100,6 +1615,10 @@ class PingService { // Notify about discovery failure for noise floor graph onDiscoveryWindowComplete?.call(false); _lastDiscPing = null; + if (_pendingDisable) { + await _executePendingDisable('after discovery window'); + return; + } _scheduleNextDiscovery(); return; } @@ -1124,6 +1643,7 @@ class PingService { timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, externalAntenna: getExternalAntenna?.call() ?? false, noiseFloor: _pendingTxNoiseFloor, + power: getPowerLevel?.call(), ); } @@ -1141,6 +1661,7 @@ class PingService { timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, externalAntenna: getExternalAntenna?.call() ?? false, noiseFloor: _pendingTxNoiseFloor, + power: getPowerLevel?.call(), ); debugLog('[DISC] Discovery drop queued (no response)'); } @@ -1151,9 +1672,14 @@ class PingService { // Fire noise floor callback (entry already in _discLogEntries via onDiscPing) onDiscoveryWindowComplete?.call(discoverySuccess); - debugLog('[DISC] Discovery window complete: ${nodes.length} nodes${discoverySuccess ? ', queued ${nodes.length} API payloads' : ''}'); + debugLog( + '[DISC] Discovery window complete: ${nodes.length} nodes${discoverySuccess ? ', queued ${nodes.length} API payloads' : ''}'); _lastDiscPing = null; + if (_pendingDisable) { + await _executePendingDisable('after discovery window'); + return; + } _scheduleNextDiscovery(); } @@ -1182,7 +1708,8 @@ class PingService { // Notify callback for countdown display (30 seconds hardcoded for discovery) onAutoPingScheduled?.call(_discoveryInterval.inMilliseconds, _skipReason); - debugLog('[DISC] Next discovery scheduled in ${_discoveryInterval.inSeconds}s'); + debugLog( + '[DISC] Next discovery scheduled in ${_discoveryInterval.inSeconds}s'); } /// Schedule next hybrid ping (alternates discovery ↔ TX) @@ -1194,17 +1721,31 @@ class PingService { _autoTimer = null; // Subtract listening window so interval is measured start-to-start - // At 15s: wait = 15000 - 5000 = 10000ms. Clamp to min 1s. - final listenMs = _rxListeningWindow.inMilliseconds; // 5000 - final waitMs = (_autoPingIntervalMs - listenMs).clamp(1000, _autoPingIntervalMs); + // TX uses 5s RX window, discovery uses 7s window + final listenMs = _nextPingIsDiscovery + ? _discoveryListeningWindow.inMilliseconds + : _rxListeningWindow.inMilliseconds; + final waitMs = + (_autoPingIntervalMs - listenMs).clamp(1000, _autoPingIntervalMs); final isNextDisc = _nextPingIsDiscovery; - debugLog('[HYBRID] Scheduling next ${isNextDisc ? "discovery" : "TX"} ping in ${waitMs}ms'); + debugLog( + '[HYBRID] Scheduling next ${isNextDisc ? "discovery" : "TX"} ping in ${waitMs}ms'); onAutoPingScheduled?.call(waitMs, _skipReason); + // See _scheduleNextAutoPing: the callback can stop auto mode synchronously. + if (!_autoPingEnabled || !_hybridModeEnabled) { + debugLog('[HYBRID] Auto mode stopped while scheduling, not arming timer'); + return; + } + _autoTimer = Timer(Duration(milliseconds: waitMs), () { if (!_autoPingEnabled || !_hybridModeEnabled) return; + if (_connection.currentStep != ConnectionStep.connected) { + debugLog('[HYBRID] Not connected, ignoring timer'); + return; + } if (_pingInProgress) { debugLog('[HYBRID] Ping already in progress, skipping'); return; @@ -1222,6 +1763,245 @@ class PingService { }); } + // ============================================ + // Targeted Mode (Zero-Hop Trace) + // ============================================ + + /// Start targeted mode - subscribes to trace data and sends first trace + Future _startTargetedMode() async { + debugLog('[TRACE] Starting targeted mode for repeater $_targetRepeaterId'); + + // Create trace tracker + final tracker = TraceTracker(); + _traceTracker = tracker; + tracker.onTraceReceived = (result) { + debugLog( + '[TRACE] Trace response received: localSnr=${result.localSnr}, remoteSnr=${result.remoteSnr}'); + }; + tracker.onWindowComplete = (result) { + debugLog( + '[TRACE] Trace window complete: ${result != null ? 'success' : 'no response'}'); + _handleTraceWindowComplete(result); + }; + + // Wire trace tracker into UnifiedRxHandler so 0x88 BLE metadata + // gets stored for the 0x89 handler + unifiedRxHandler?.traceTracker = tracker; + + // Subscribe to 0x89 TraceData stream for actual trace payloads + _traceDataSubscription = _connection.traceDataStream.listen((raw) { + final tt = _traceTracker; + if (tt != null && tt.isListening) { + tt.handlePacket(raw, tt.pendingBleSnr, tt.pendingBleRssi); + } + }); + + // Send first trace immediately + await _sendTargetedPing(); + } + + /// Stop targeted mode - cleans up tracker and subscription + void _stopTargetedMode() { + debugLog('[TRACE] Stopping targeted mode'); + _targetedTimer?.cancel(); + _targetedTimer = null; + _traceDataSubscription?.cancel(); + _traceDataSubscription = null; + unifiedRxHandler?.traceTracker = null; + _traceTracker?.dispose(); + _traceTracker = null; + _lastTargetedPosition = null; + } + + /// Send a targeted ping (trace path) and start listening window + Future _sendTargetedPing() async { + if (!_autoPingEnabled || !_targetedModeEnabled) { + debugLog('[TRACE] Not in targeted mode, skipping trace'); + return; + } + + final targetId = _targetRepeaterId; + if (targetId == null || targetId.isEmpty) { + debugLog('[TRACE] No target repeater ID, skipping trace'); + _scheduleNextTargetedPing(); + return; + } + + // Check GPS + final position = _gpsService.lastPosition; + if (position == null) { + debugLog('[TRACE] No GPS position, skipping trace'); + _pingInProgress = false; + _scheduleNextTargetedPing(); + return; + } + + // Check minimum distance from last trace (25m) + final lastPos = _lastTargetedPosition; + if (lastPos != null) { + final distance = Geolocator.distanceBetween( + lastPos.latitude, + lastPos.longitude, + position.latitude, + position.longitude, + ); + if (distance < _gpsService.configuredMinDistance) { + debugLog( + '[TRACE] Too close to last trace (${distance.toStringAsFixed(1)}m < ${_gpsService.configuredMinDistance.toInt()}m), skipping'); + _skipReason = 'too close'; + _pingInProgress = false; + _scheduleNextTargetedPing(); + return; + } + } + + // Clear skip reason since we're proceeding + _skipReason = null; + + // Signal "Sending..." to UI + _pingInProgress = true; + onPingProgressChanged?.call(); + + // Capture noise floor + final noiseFloor = _connection.lastNoiseFloor; + _pendingTxNoiseFloor = noiseFloor; + + // Create trace log entry immediately + final traceEntry = TraceLogEntry( + timestamp: DateTime.now(), + latitude: position.latitude, + longitude: position.longitude, + targetRepeaterId: targetId, + noiseFloor: noiseFloor, + success: false, // Will be updated after window completes + ); + onTracePing?.call(traceEntry); + + debugLog( + '[TRACE] Sending trace to $targetId at ${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'); + + try { + // Play transmit sound + _audioService?.playTransmitSound(); + + // Convert hex repeater ID to bytes (trace uses separate byte size: 1, 2, or 4) + final traceBytes = _traceHopBytes; + final repeaterIdBytes = Uint8List(traceBytes); + for (int i = 0; i < traceBytes && i * 2 + 2 <= targetId.length; i++) { + repeaterIdBytes[i] = + int.parse(targetId.substring(i * 2, i * 2 + 2), radix: 16); + } + + // Send trace path and get tag + final tag = await _connection.sendTracePath(repeaterIdBytes, + hopBytes: traceBytes); + + // Start tracking with the tag + _traceTracker?.startTracking( + tag: tag, + targetRepeaterId: targetId, + windowDuration: _rxListeningWindow, + ); + + // Start listening window countdown display + _discoveryWindowCountdown.start(_rxListeningWindow.inMilliseconds); + + // Clear pingInProgress now that trace window is active + _pingInProgress = false; + + // Update last targeted position for 25m check + _lastTargetedPosition = position; + + // Follow with the display anchor so the map's distance readout tracks + // traces too, not just TX (#501) + _gpsService.markActivityPosition(position); + } catch (e) { + _pingInProgress = false; + debugError('[TRACE] Failed to send trace: $e'); + if (_pendingDisable) { + await _executePendingDisable('trace send failed'); + return; + } + _scheduleNextTargetedPing(); + } + } + + /// Handle trace window completion + Future _handleTraceWindowComplete(TraceResult? result) async { + _discoveryWindowCountdown.stop(); + final position = _lastTargetedPosition; + final targetId = _targetRepeaterId ?? ''; + + if (result != null && result.success && position != null) { + debugLog( + '[TRACE] Trace successful: localSnr=${result.localSnr}, remoteSnr=${result.remoteSnr}'); + + // Queue to API (only successful traces) + _apiQueue.enqueueTrace( + latitude: position.latitude, + longitude: position.longitude, + repeaterId: targetId, + localSnr: result.localSnr, + localRssi: result.localRssi, + remoteSnr: result.remoteSnr, + timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + externalAntenna: getExternalAntenna?.call() ?? false, + noiseFloor: _pendingTxNoiseFloor, + power: getPowerLevel?.call(), + ); + + // Update stats + _stats = _stats.copyWith(traceCount: _stats.traceCount + 1); + onStatsUpdated?.call(_stats); + + // Play receive sound for successful trace + _audioService?.playReceiveSound(); + } else { + debugLog('[TRACE] Trace failed: no response from $targetId'); + // Failed traces are NOT posted to API (local visual only) + } + + // Notify for noise floor graph and log updates + onTraceWindowComplete?.call(result); + + if (_pendingDisable) { + await _executePendingDisable('after trace window'); + return; + } + _scheduleNextTargetedPing(); + } + + /// Schedule next targeted ping after interval + void _scheduleNextTargetedPing() { + if (!_autoPingEnabled || !_targetedModeEnabled) { + debugLog('[TRACE] Not in targeted mode, not scheduling next trace'); + return; + } + + _targetedTimer?.cancel(); + _targetedTimer = Timer(Duration(milliseconds: _autoPingIntervalMs), () { + debugLog('[TRACE] Targeted ping timer fired'); + if (_connection.currentStep != ConnectionStep.connected) { + debugLog('[TRACE] Not connected, ignoring timer'); + return; + } + if (_autoPingEnabled && _targetedModeEnabled) { + if (_pingInProgress) { + debugLog('[TRACE] Ping already in progress, skipping'); + return; + } + _skipReason = null; + _sendTargetedPing(); + } + }); + + // Notify callback for countdown display + onAutoPingScheduled?.call(_autoPingIntervalMs, _skipReason); + + debugLog( + '[TRACE] Next targeted ping scheduled in ${_autoPingIntervalMs}ms'); + } + /// Stop any active TX echo tracking window /// Called when disabling auto mode to prevent late timer callbacks from /// triggering pings during cooldown (race condition fix) @@ -1243,7 +2023,10 @@ class PingService { _rxWindowTimer = null; _autoTimer?.cancel(); _autoTimer = null; + _pendingDisableTimeout?.cancel(); + _pendingDisableTimeout = null; _stopDiscoveryMode(); + _stopTargetedMode(); _wakelockService.dispose(); } } @@ -1252,32 +2035,32 @@ class PingService { enum PingValidation { /// All conditions met, can ping valid, - + /// Not connected to device notConnected, - + /// External antenna not configured externalAntennaRequired, - + /// Power level not set (unknown device model) powerLevelRequired, - + /// No GPS lock noGpsLock, - + /// GPS data too old (> 60 seconds) gpsDataStale, - + /// GPS accuracy too low (> 100 meters) gpsInaccurate, - + /// Outside service area (zone validation handled by API) /// Reserved for future use with dynamic zone boundaries outsideGeofence, - + /// Too close to last ping (< 25m) tooCloseToLastPing, - + /// Cooldown period active (< 5s since last ping) cooldownActive, @@ -1308,7 +2091,7 @@ extension PingValidationExtension on PingValidation { case PingValidation.outsideGeofence: return 'Outside service area'; case PingValidation.tooCloseToLastPing: - return 'Move 25m before next ping'; + return 'Move ${PingService.currentMinDistance}m before next ping'; case PingValidation.cooldownActive: return 'Wait 5 seconds between pings'; case PingValidation.manualCooldownActive: diff --git a/lib/services/portal_account_service.dart b/lib/services/portal_account_service.dart new file mode 100644 index 00000000..62eddd3f --- /dev/null +++ b/lib/services/portal_account_service.dart @@ -0,0 +1,965 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:app_links/app_links.dart'; +import 'package:http/http.dart' as http; +import 'package:url_launcher/url_launcher.dart'; + +import '../utils/constants.dart'; +import '../utils/debug_logger_io.dart'; +import '../utils/pkce.dart'; +import 'portal_token_store.dart'; + +/// The MyMeshMapper portal wire contract. +/// +/// Mirror of `MeshMapper_Server/docs/SPEC-app-portal-link.md`. If the server +/// renames a route, a parameter or the host, THIS CLASS is the only thing that +/// has to change. +class PortalApi { + PortalApi._(); + + static const String baseUrl = 'https://portal.meshmapper.net'; + static const String authorizePath = '/portal.php'; + static const String apiPath = '/portal_app_api.php'; + + /// Deliberately NOT `meshmapper` — that bare scheme is a documented + /// clipboard-only format (`docs/CUSTOM_API_ENDPOINT.md`, + /// `meshmapper://custom-api?...`) and OS-registering it would hijack it. + static const String callbackScheme = 'meshmapper-auth'; + static const String callbackHost = 'callback'; + static const String redirectUri = '$callbackScheme://$callbackHost'; + + static const Duration requestTimeout = Duration(seconds: 10); + + /// A stashed PKCE pair older than this is dead; the portal's own code TTL is + /// 300s, so 10 minutes is generous and still bounds the window. + static const Duration pkceTtl = Duration(minutes: 10); + + /// `me` writes the site-wide auth DB, so it is refreshed at most hourly. + static const Duration meThrottle = Duration(hours: 1); + + /// Every 429 on this lane carries `Retry-After` in delta-seconds, but a + /// proxy can strip it or rewrite it as an HTTP-date. This is what an + /// unusable header costs. + static const Duration defaultRetryAfter = Duration(minutes: 5); + + /// Ceiling for a server-supplied backoff. The longest the portal sends is + /// 600s, so anything past an hour is a broken header, not an instruction. + static const Duration maxRetryAfter = Duration(hours: 1); + + static Uri authorizeUrl({ + required String codeChallenge, + required String state, + }) => + Uri.parse('$baseUrl$authorizePath').replace(queryParameters: { + 'app_authorize': '1', + 'code_challenge': codeChallenge, + 'code_challenge_method': 'S256', + 'redirect_uri': redirectUri, + 'state': state, + }); + + /// `?action=` is the primary router; PATH_INFO is not used. + static Uri action(String name) => + Uri.parse('$baseUrl$apiPath').replace(queryParameters: {'action': name}); +} + +/// The signed-in portal identity. +class PortalAccount { + final int id; + final String username; + final String displayName; + + const PortalAccount({ + required this.id, + required this.username, + required this.displayName, + }); + + /// Parses the API shape `{id, username, display_name}`. `display_name` is a + /// lazily-added nullable column server-side, so it coalesces to `username`. + static PortalAccount? fromJson(Map? json) { + if (json == null) return null; + final id = (json['id'] as num?)?.toInt(); + final username = json['username']; + if (id == null || username is! String || username.isEmpty) return null; + final display = json['display_name']; + return PortalAccount( + id: id, + username: username, + displayName: + (display is String && display.isNotEmpty) ? display : username, + ); + } + + /// Hive cache shape (`portal_account_info`). + Map toCache() => { + 'user_id': id, + 'username': username, + 'display_name': displayName, + }; + + static PortalAccount? fromCache(Map? cache) { + if (cache == null) return null; + final id = (cache['user_id'] as num?)?.toInt(); + final username = cache['username']; + if (id == null || username is! String || username.isEmpty) return null; + final display = cache['display_name']; + return PortalAccount( + id: id, + username: username, + displayName: + (display is String && display.isNotEmpty) ? display : username, + ); + } +} + +/// One companion radio bound to the account (`me` → `pubkeys[]`). +class LinkedPubkey { + final String pubkey; // UPPER 64-hex + final String label; + final String name; + final int points; + + const LinkedPubkey({ + required this.pubkey, + required this.label, + required this.name, + required this.points, + }); + + static LinkedPubkey? fromJson(Map json) { + final pubkey = json['pubkey']; + if (pubkey is! String || pubkey.isEmpty) return null; + return LinkedPubkey( + pubkey: pubkey.toUpperCase(), + label: json['label'] is String ? json['label'] as String : '', + name: json['name'] is String ? json['name'] as String : '', + points: (json['points'] as num?)?.toInt() ?? 0, + ); + } +} + +/// Outcome of an `action=link` POST. +sealed class LinkResult { + const LinkResult(); +} + +class LinkSuccess extends LinkResult { + final String pubkey; + + /// True when the server answered `already:true` (idempotent relink). + final bool already; + + const LinkSuccess({required this.pubkey, required this.already}); +} + +/// The pubkey belongs to a different account. `UNIQUE(pubkey)` means this is +/// permanent until the other owner unlinks — never retry it silently. +class LinkAlreadyLinkedOtherAccount extends LinkResult { + const LinkAlreadyLinkedOtherAccount(); +} + +/// The account still holds placeholder devices. The app lane NEVER adopts — +/// adoption is irreversible and belongs in the browser portal. +class LinkAdoptionRequired extends LinkResult { + final int devices; + const LinkAdoptionRequired(this.devices); +} + +class LinkUnauthorized extends LinkResult { + const LinkUnauthorized(); +} + +class LinkNetworkError extends LinkResult { + final String detail; + const LinkNetworkError(this.detail); +} + +class LinkServerError extends LinkResult { + final String code; + final int statusCode; + const LinkServerError(this.code, this.statusCode); +} + +/// How a link attempt should be presented. Returned by `AppStateProvider`, not +/// by the service — the provider folds persistence and backoff into it. +enum PortalLinkStatus { + skipped, + linked, + adoptionRequired, + alreadyLinkedOtherAccount, + unauthorized, + failed, +} + +class PortalLinkOutcome { + final PortalLinkStatus status; + final int adoptionDeviceCount; + final String? accountName; + + /// How long the portal asked us to stay off the link lane, when that is why + /// this attempt did not happen. Null for every other outcome. + final Duration? retryAfter; + + const PortalLinkOutcome( + this.status, { + this.adoptionDeviceCount = 0, + this.accountName, + this.retryAfter, + }); +} + +/// One HTTP answer from the portal's machine lane. +class _PortalResponse { + /// 0 means no HTTP response at all (timeout, DNS, socket). + final int status; + final Map json; + + /// The value of `{"error": "..."}` when the body carried one. + final String? error; + + /// The `Retry-After` a 429 carried, already parsed and clamped. Null on + /// every answer that was not rate limited. + final Duration? retryAfter; + + const _PortalResponse(this.status, this.json, this.error, {this.retryAfter}); + + bool get ok => status >= 200 && status < 300 && json['ok'] == true; + bool get networkFailure => status == 0; + + /// Terminal on this lane: the portal never softens a 429 on a retry, it + /// re-arms the block (see [PortalAccountService.rateLimitBackoff]). + bool get rateLimited => status == 429; +} + +/// The MyMeshMapper portal lane. +/// +/// Deliberately separate from `ApiService`: different host, different auth +/// model (long-lived bearer vs. per-session geo-auth), different body encoding +/// (form vs. JSON). Nothing here may block or fail a wardriving connection. +class PortalAccountService { + final http.Client _client; + final PortalTokenStore _store; + final AppLinks? _appLinks; + final Future Function(Uri uri) _launcher; + + String? _token; + PortalAccount? _account; + List _linkedPubkeys = const []; + DateTime? _lastMeAt; + PendingPkce? _pendingPkce; + String? _lastCode; + StreamSubscription? _linkSub; + + /// Codes already exchanged this app session (dedupe — see handleAuthCallback). + final Set _consumedCodes = {}; + + /// States whose `error=` callback has already been honoured this session + /// (dedupe — see _handleErrorCallback). + final Set _consumedErrorStates = {}; + + /// Route -> earliest time that route may be called again. Filled from a + /// 429's `Retry-After`. In-memory: a restart is a fresh start, and the + /// server re-answers 429 if the block is still live. + final Map _rateLimitedUntil = {}; + + /// Optional friendly label sent with `token` and `link` (device name). + String? Function()? deviceLabelProvider; + + /// Fired whenever the cached identity or linked-device list changes. + void Function()? onAccountChanged; + + /// Fired on sign-out. `reason` is `token_invalid` or `user`. + void Function(String reason)? onSignedOut; + + /// Fired once per completed sign-in attempt. + void Function(bool success, String? errorCode)? onSignInComplete; + + PortalAccountService({ + http.Client? client, + PortalTokenStore? store, + AppLinks? appLinks, + Future Function(Uri uri)? launcher, + }) : _client = client ?? http.Client(), + _store = store ?? SecureTokenStore(), + _appLinks = appLinks, + _launcher = launcher ?? _launchExternally; + + /// `externalApplication` is load-bearing: an in-app WebView would see the + /// user's portal password, which is exactly what this design avoids. + static Future _launchExternally(Uri uri) => + launchUrl(uri, mode: LaunchMode.externalApplication); + + bool get isSignedIn => _token != null; + PortalAccount? get account => _account; + List get linkedPubkeys => List.unmodifiable(_linkedPubkeys); + + /// Restore the identity cached in Hive so the UI has a name before any + /// network call happens. + void hydrateAccount(PortalAccount account) => _account = account; + + /// Restore the cached linked-device list from Hive. + /// + /// Load-bearing: `link`/`unlink` mutate THIS list and the provider mirrors it + /// back wholesale on `onAccountChanged`. Without the hydrate, linking a new + /// radio would publish a one-entry list and wipe every previously cached + /// pubkey. Only membership is restored — label/name/points are `me` data and + /// are refilled by the next `refreshMe`. Deliberately does NOT fire + /// `onAccountChanged`: this runs during load, before any listener cares. + void hydrateLinkedPubkeys(List pubkeys) { + _linkedPubkeys = pubkeys + .where((pubkey) => pubkey.isNotEmpty) + .map((pubkey) => LinkedPubkey( + pubkey: pubkey.toUpperCase(), + label: '', + name: '', + points: 0, + )) + .toList(growable: false); + } + + Future init() async { + _token = await _store.readToken(); + _log('init: signedIn=${_token != null}'); + + final links = _appLinks; + if (links == null) { + _log('init: deep links not wired (web or test)'); + return; + } + + // Subscribe FIRST. In app_links 6.x uriLinkStream carries the cold-start + // link as well as later ones, so a late subscription can miss it outright. + _linkSub = links.uriLinkStream.listen( + (uri) => unawaited(handleAuthCallback(uri)), + onError: (Object e) => _warn('link stream error: ${e.runtimeType}'), + ); + + // Fire-and-forget on purpose. The caller AWAITS init() during app startup, + // and handleAuthCallback runs a token exchange with a 10s timeout — that is + // the feature's own cold-start path, and awaiting it here would stall BLE + // setup and auto-connect behind it. The token read and the stream subscribe + // above are the only parts startup actually has to wait for. + unawaited(links.getInitialLink().then((initial) async { + if (initial != null) await handleAuthCallback(initial); + }).catchError((Object e) { + _warn('getInitialLink failed: ${e.runtimeType}'); + })); + } + + /// Re-check the last deep link on app resume — iOS can deliver the callback + /// while the Dart isolate is suspended. + Future checkLinkOnResume() async { + final links = _appLinks; + if (links == null) return; + try { + final latest = await links.getLatestLink(); + if (latest != null) await handleAuthCallback(latest); + } catch (e) { + _warn('getLatestLink failed: ${e.runtimeType}'); + } + } + + Future beginSignIn() async { + final pkce = PkcePair.generate(); + final pending = PendingPkce( + verifier: pkce.verifier, + state: pkce.state, + createdAt: DateTime.now(), + ); + _pendingPkce = pending; + await _store.writePendingPkce(pending); + + final url = PortalApi.authorizeUrl( + codeChallenge: pkce.challenge, + state: pkce.state, + ); + _log('opening the portal consent page (${url.host}${url.path})'); + try { + final launched = await _launcher(url); + if (!launched) _warn('the system browser refused the authorize URL'); + return launched; + } catch (e) { + _err('could not open the authorize URL: ${e.runtimeType}'); + return false; + } + } + + /// Handle one inbound deep link. Anything that is not our callback is + /// silently ignored — other schemes belong to other features. + Future handleAuthCallback(Uri uri) async { + if (uri.scheme != PortalApi.callbackScheme || + uri.host != PortalApi.callbackHost) { + return; + } + // Query stripped on purpose: it carries the code and the state. + _log('callback received: ${uri.scheme}://${uri.host}${uri.path}'); + + final error = uri.queryParameters['error']; + final code = uri.queryParameters['code']; + final state = uri.queryParameters['state']; + final hasError = error != null && error.isNotEmpty; + + // `state` is what ties any callback — success OR failure — to an attempt + // this app actually started. + if (state == null || state.isEmpty) { + _warn('callback missing state — ignoring'); + return; + } + + if (hasError) { + await _handleErrorCallback(error, state); + return; + } + + if (code == null || code.isEmpty) { + _warn('callback missing code — ignoring'); + return; + } + + // app_links 6.x can deliver the same cold-start URI twice (getInitialLink + // AND uriLinkStream) and can deliver both CONCURRENTLY — init() runs the + // stream listener unawaited while getInitialLink() is still in flight. The + // portal burns a code on first use, so a second exchange fails with + // code_invalid and looks like a broken sign-in. + // + // `Set.add` is the atomic test-and-set, and it has to happen HERE: every + // later checkpoint sits behind `await _store.readPendingPkce()`, and an + // await between the test and the insert lets both deliveries through. The + // claim is released again on each path below that decides not to exchange. + if (!_consumedCodes.add(code)) { + _log('callback duplicate — this code was already exchanged'); + return; + } + + final pending = _pendingPkce ?? await _store.readPendingPkce(); + if (pending == null) { + _consumedCodes.remove(code); + _warn('callback with no pending PKCE pair — ignoring'); + return; + } + // Arm the redactor. On a cold start the pair comes off disk and + // `_pendingPkce` would otherwise stay null, leaving `_liveSecrets` empty — + // the backstop would be inert for the rest of this flow, which is exactly + // the stretch that handles the verifier and the token. + _pendingPkce = pending; + + if (DateTime.now().difference(pending.createdAt) > PortalApi.pkceTtl) { + _consumedCodes.remove(code); + _warn('callback pending pair expired — discarding'); + _pendingPkce = null; + await _store.deletePendingPkce(); + // A real attempt was live and just died; the UI must stop waiting. + onSignInComplete?.call(false, 'expired'); + return; + } + if (pending.state != state) { + // Do NOT burn the pair, and release the code: the genuine callback may + // still be coming and may legitimately carry this very code. + _consumedCodes.remove(code); + _warn('callback state mismatch — ignoring'); + return; + } + + // Burn the pair BEFORE the exchange: one attempt per authorize. The code + // itself was already claimed by the atomic add above. + _pendingPkce = null; + await _store.deletePendingPkce(); + + _lastCode = code; + try { + await _exchangeCode(code: code, verifier: pending.verifier); + } catch (e) { + // handleAuthCallback is driven unawaited from the uriLinkStream listener, + // so anything escaping here is an unhandled zone error: the attempt would + // die silently with the code already claimed and the UI still spinning. + _err('token exchange crashed: ${e.runtimeType}'); + onSignInComplete?.call(false, 'bad_response'); + } finally { + _lastCode = null; + } + } + + /// Handle a callback that reported `error=`. + /// + /// A deep link is unauthenticated — ANY app on the device can fire + /// `meshmapper-auth://callback?error=x`. Honouring that blindly let a foreign + /// app destroy a legitimate in-flight pair (a sign-in DoS) and push an + /// arbitrary string into the completion callback. So an error is believed + /// only when it answers a sign-in this app actually started: a live pending + /// pair whose state matches. Everything else is dropped without a trace of + /// the attacker's string. + Future _handleErrorCallback(String error, String state) async { + // Same atomic test-and-set as the code path, keyed on the state. Sequential + // dedupe falls out of the pair being burned, but two CONCURRENT deliveries + // both park on the store read below and would both fire onSignInComplete. + // The claim is released again on every path that decides not to honour it. + if (!_consumedErrorStates.add(state)) { + _log('callback duplicate — this attempt was already declined'); + return; + } + + final PendingPkce? pending; + try { + pending = _pendingPkce ?? await _store.readPendingPkce(); + } catch (e) { + _consumedErrorStates.remove(state); + _err('reading the pending pair for a declined sign-in failed: ' + '${e.runtimeType}'); + return; + } + if (pending == null) { + _consumedErrorStates.remove(state); + _warn('callback reported an error with no sign-in in flight — ignoring'); + return; + } + _pendingPkce = pending; + if (pending.state != state) { + // Leave the pair alone: the genuine callback may still be coming. + _consumedErrorStates.remove(state); + _warn('callback reported an error for a different attempt — ignoring'); + return; + } + + _pendingPkce = null; + try { + await _store.deletePendingPkce(); + } catch (e) { + // Not fatal — the pair is dead in memory and the stored copy carries a + // TTL. What must NOT happen is this escaping as a zone error on the + // unawaited uriLinkStream path, leaving the UI spinning on a sign-in + // that has already been refused. + _warn('clearing the declined pending pair failed: ${e.runtimeType}'); + } + final reason = _sanitizeErrorCode(error); + _log('the portal declined the sign-in (reason=$reason)'); + onSignInComplete?.call(false, reason); + } + + /// The portal's error codes are lowercase snake_case (`access_denied`). + /// Anything else came from something that is not the portal, so it is + /// collapsed to a fixed token rather than forwarded to the UI or a log. + static String _sanitizeErrorCode(String raw) => + _errorCodePattern.hasMatch(raw) ? raw : 'denied'; + + static final RegExp _errorCodePattern = RegExp(r'^[a-z_]{1,32}$'); + + Future _exchangeCode({ + required String code, + required String verifier, + }) async { + final label = deviceLabelProvider?.call(); + final response = await _post( + 'token', + { + 'code': code, + 'code_verifier': verifier, + if (label != null && label.isNotEmpty) 'device_label': label, + }, + authenticated: false, + ); + + if (!response.ok) { + _warn('token exchange failed (status=${response.status}, ' + 'error=${response.error ?? 'none'})'); + onSignInComplete?.call(false, response.error ?? 'network'); + return; + } + + final token = response.json['token']; + // Type CHECK, not a cast: `"user": []` or `"user": "bob"` would throw a + // TypeError out of an unawaited future. fromJson's null path already + // reports this as an unusable body. + final rawUser = response.json['user']; + final account = PortalAccount.fromJson( + rawUser is Map ? rawUser : null); + if (token is! String || token.isEmpty || account == null) { + _warn('token exchange returned an unusable body'); + onSignInComplete?.call(false, 'bad_response'); + return; + } + + _token = token; + _account = account; + await _store.writeToken(token); + _log('signed in as ${account.username} (id=${account.id})'); + onAccountChanged?.call(); + onSignInComplete?.call(true, null); + + // The exchange carries the identity but NOT the pubkeys, so the account + // card would sit at "0 device(s) on this account" until the next radio + // connect happened to run `me`. Pull the list now so a fresh sign-in shows + // the user it worked. Deliberately AFTER the callbacks above: the sign-in + // is already a success, and a failed list must not colour it. + await refreshMe(); + } + + /// True only for the one answer that means "this token is dead". A bare 401 + /// (captive portal, WAF) must NOT sign the user out. + bool _isTokenInvalid(_PortalResponse response) => + response.status == 401 && response.error == 'token_invalid'; + + /// How long the portal has told us to stay off [action], or null when that + /// route is clear. + /// + /// Worth honouring precisely: the server's buckets slide, and a request made + /// while blocked does NOT reset the count, it re-arms a FRESH penalty. A + /// client that keeps knocking extends its own lockout indefinitely. + Duration? rateLimitBackoff(String action) { + final until = _rateLimitedUntil[action]; + if (until == null) return null; + final left = until.difference(DateTime.now()); + if (left > Duration.zero) return left; + _rateLimitedUntil.remove(action); + return null; + } + + /// The longest live block across the two routes one link attempt walks. + /// Either one blocked means the attempt cannot finish. + Duration? get linkLaneBackoff { + final nonce = rateLimitBackoff('nonce'); + final link = rateLimitBackoff('link'); + if (nonce == null) return link; + if (link == null) return nonce; + return nonce > link ? nonce : link; + } + + /// `Retry-After` is delta-seconds on this lane. An HTTP-date is legal HTTP + /// and useless here, so anything that is not a sane integer falls back to + /// [PortalApi.defaultRetryAfter] rather than to no backoff at all. + static Duration _parseRetryAfter(String? raw) { + final seconds = int.tryParse(raw?.trim() ?? ''); + if (seconds == null || seconds <= 0) return PortalApi.defaultRetryAfter; + final backoff = Duration(seconds: seconds); + return backoff > PortalApi.maxRetryAfter + ? PortalApi.maxRetryAfter + : backoff; + } + + /// Ask the portal for a fresh 32-byte challenge bound to [pubkeyHex]. + /// Returns the 64-hex nonce, or null on any failure (never throws). + Future requestNonce(String pubkeyHex) async { + final response = await _post('nonce', {'pubkey': pubkeyHex.toUpperCase()}); + if (_isTokenInvalid(response)) { + await _forceSignOut('token_invalid'); + return null; + } + // The nonce route answers {nonce, expires_at} with no `ok` flag. + final nonce = response.json['nonce']; + if (response.status >= 200 && + response.status < 300 && + nonce is String && + _noncePattern.hasMatch(nonce)) { + _log('nonce issued for ${_pk(pubkeyHex)}'); + return nonce; + } + _warn('nonce request failed for ${_pk(pubkeyHex)} ' + '(status=${response.status}, error=${response.error ?? 'none'})'); + return null; + } + + static final RegExp _noncePattern = RegExp(r'^[0-9a-fA-F]{64}$'); + + /// Post the radio's Ed25519 signature over the raw nonce bytes. + Future linkDevice({ + required String pubkey, + required String nonce, + required String signature, + String? label, + }) async { + final upper = pubkey.toUpperCase(); + final response = await _post('link', { + 'pubkey': upper, + 'nonce': nonce, + 'signature': signature, + if (label != null && label.isNotEmpty) 'label': label, + }); + + if (_isTokenInvalid(response)) { + await _forceSignOut('token_invalid'); + return const LinkUnauthorized(); + } + if (response.status == 401) { + return const LinkNetworkError('bare 401 (captive portal?)'); + } + if (response.networkFailure) { + return LinkNetworkError(response.error ?? 'network'); + } + if (response.ok) { + final already = response.json['already'] == true; + _log('link ok for ${_pk(upper)} (already=$already)'); + if (!_linkedPubkeys.any((p) => p.pubkey == upper)) { + _linkedPubkeys = [ + ..._linkedPubkeys, + LinkedPubkey( + pubkey: upper, + label: label ?? '', + name: response.json['name'] is String + ? response.json['name'] as String + : '', + points: (response.json['points'] as num?)?.toInt() ?? 0, + ), + ]; + onAccountChanged?.call(); + } + return LinkSuccess(pubkey: upper, already: already); + } + + switch (response.error) { + case 'already_linked': + _log('link refused for ${_pk(upper)}: owned by another account'); + return const LinkAlreadyLinkedOtherAccount(); + case 'adoption_required': + final devices = (response.json['devices'] as num?)?.toInt() ?? 0; + _log('link needs browser adoption first ' + '($devices placeholder device(s))'); + return LinkAdoptionRequired(devices); + default: + _warn('link failed for ${_pk(upper)} ' + '(status=${response.status}, error=${response.error ?? 'none'})'); + return LinkServerError(response.error ?? 'unknown', response.status); + } + } + + /// In-app recovery for a mislink — without it a phone mistake needs a laptop. + Future unlinkDevice(String pubkey) async { + final upper = pubkey.toUpperCase(); + final response = await _post('unlink', {'pubkey': upper}); + if (_isTokenInvalid(response)) { + await _forceSignOut('token_invalid'); + return false; + } + if (!response.ok) { + _warn('unlink failed for ${_pk(upper)} (status=${response.status})'); + return false; + } + _linkedPubkeys = + _linkedPubkeys.where((p) => p.pubkey != upper).toList(growable: false); + _log('unlinked ${_pk(upper)}'); + onAccountChanged?.call(); + return true; + } + + /// Refresh the identity and the linked-device list. + /// + /// Throttled to once an hour: the bearer gate touches the SITE-WIDE auth DB + /// on every call, so a chatty client writes it on every reconnect. + Future refreshMe({bool force = false}) async { + if (_token == null) return false; + + // `force` skips the LOCAL throttle, never a backoff the server asked for. + // The `me` bucket is 12/hour with a 600s rolling penalty, so a user + // tapping Refresh past the cap would extend their own lockout with every + // tap. + final blocked = rateLimitBackoff('me'); + if (blocked != null) { + _log('me: rate limited, ${blocked.inSeconds}s left'); + return false; + } + + final last = _lastMeAt; + if (!force && + last != null && + DateTime.now().difference(last) < PortalApi.meThrottle) { + _log('me: throttled'); + return false; + } + _lastMeAt = DateTime.now(); + + final response = await _post('me', const {}); + if (_isTokenInvalid(response)) { + await _forceSignOut('token_invalid'); + return false; + } + if (!response.ok) { + _warn('me failed (status=${response.status}, ' + 'error=${response.error ?? 'none'})'); + return false; + } + + // Type CHECK, not a cast — same hostile shape the token exchange guards + // against (`"user": []` would throw a TypeError out of a plain cast). + final rawUser = response.json['user']; + final account = PortalAccount.fromJson( + rawUser is Map ? rawUser : null); + if (account != null) _account = account; + + final raw = response.json['pubkeys']; + if (raw is List) { + _linkedPubkeys = raw + .whereType() + .map((entry) => LinkedPubkey.fromJson(entry.cast())) + .whereType() + .toList(growable: false); + } + _log('me ok: ${_linkedPubkeys.length} linked device(s)'); + onAccountChanged?.call(); + return true; + } + + /// Sign out. The LOCAL clear always happens; the server revoke is best-effort + /// with a single retry, because a live server-side token with no local copy + /// is an orphan the user cannot revoke from the app. + Future logout() async { + final token = _token; + await _forceSignOut('user'); + if (token == null) return; + + final first = await _postWithToken('logout', const {}, token); + if (first.ok) { + _log('logout: server token revoked'); + return; + } + if (first.rateLimited) { + // The retry would land inside the block and re-arm it. The orphaned + // server token is the accepted trade, same as a failed retry. + _warn('logout revoke rate limited (retry after ' + '${first.retryAfter?.inSeconds ?? 0}s); the server token stays ' + 'until it expires'); + return; + } + _warn('logout revoke failed (status=${first.status}) — retrying once'); + final retry = await _postWithToken('logout', const {}, token); + if (!retry.ok) { + _warn('logout revoke retry failed (status=${retry.status}); ' + 'the server token stays until it expires'); + } + } + + Future<_PortalResponse> _post( + String action, + Map body, { + bool authenticated = true, + }) { + // An authenticated call with no token can only ever be refused, and sending + // it header-less would draw a bare 401 the callers have to disambiguate — + // worse, a race that signs out mid-link would answer `token_invalid` and + // fire a SECOND sign-out event. Answer locally instead; `status == 0` makes + // this a network failure to every caller, which is the harmless verdict. + if (authenticated && _token == null) { + _warn('$action skipped: signed out'); + return Future.value(const _PortalResponse(0, {}, 'no_token')); + } + return _postWithToken(action, body, authenticated ? _token : null); + } + + Future<_PortalResponse> _postWithToken( + String action, + Map body, + String? token, + ) async { + // Answer a live block locally instead of sending. The portal's buckets + // slide and a blocked request does NOT reset the count, it re-arms a FRESH + // penalty, so every extra knock extends the user's own lockout. Deliberately + // does NOT touch _rateLimitedUntil: only a real server 429 moves that clock. + final blocked = rateLimitBackoff(action); + if (blocked != null) { + _warn('$action not sent: rate limited, ${blocked.inSeconds}s left'); + return _PortalResponse(429, const {}, 'rate_limited', + retryAfter: blocked); + } + + final headers = { + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', + 'Accept': 'application/json', + 'User-Agent': AppConstants.userAgent, + }; + if (token != null) { + // Dual-send: Apache/FPM drops Authorization without CGIPassAuth, and the + // custom header is the proven-to-arrive fallback on this stack. + headers['Authorization'] = 'Bearer $token'; + headers['X-MM-App-Token'] = token; + } + + try { + final response = await _client + .post(PortalApi.action(action), headers: headers, body: body) + .timeout(PortalApi.requestTimeout); + + var json = const {}; + try { + final decoded = jsonDecode(response.body); + if (decoded is Map) json = decoded; + } catch (_) { + // Captive portal HTML / WAF block page — opaque, not an API answer. + } + final error = json['error'] is String ? json['error'] as String : null; + + Duration? retryAfter; + if (response.statusCode == 429) { + retryAfter = _parseRetryAfter(response.headers['retry-after']); + _rateLimitedUntil[action] = DateTime.now().add(retryAfter); + _warn('$action rate limited, backing off ${retryAfter.inSeconds}s'); + } + return _PortalResponse(response.statusCode, json, error, + retryAfter: retryAfter); + } on TimeoutException { + _warn('$action timed out after ${PortalApi.requestTimeout.inSeconds}s'); + return const _PortalResponse(0, {}, 'timeout'); + } catch (e) { + _warn('$action network failure: ${e.runtimeType}'); + return const _PortalResponse(0, {}, 'network'); + } + } + + /// Drop local credentials. Called for an explicit sign-out and for the one + /// server answer that means "this token is dead": 401 + `token_invalid`. + Future _forceSignOut(String reason) async { + _token = null; + _account = null; + _linkedPubkeys = const []; + _lastMeAt = null; + await _store.deleteToken(); + await _store.deletePendingPkce(); + _pendingPkce = null; + _log('signed out ($reason)'); + onSignedOut?.call(reason); + onAccountChanged?.call(); + } + + /// 8-char uppercase prefix — full public keys never reach a log. + String _pk(String? pubkey) { + if (pubkey == null || pubkey.isEmpty) return 'none'; + final upper = pubkey.toUpperCase(); + return upper.length <= 8 ? upper : upper.substring(0, 8); + } + + void _log(String message) => debugLog('[ACCOUNT] ${_redact(message)}'); + void _warn(String message) => debugWarn('[ACCOUNT] ${_redact(message)}'); + void _err(String message) => debugError('[ACCOUNT] ${_redact(message)}'); + + static final RegExp _bearerPattern = + RegExp(r'Bearer\s+[A-Za-z0-9._~+/=-]{8,}'); + + /// Mechanical backstop on top of "never interpolate a secret". Debug logging + /// is ON in release builds and log files are uploaded verbatim with bug + /// reports, so discipline alone WILL eventually leak. + String _redact(String message) { + var out = message; + for (final secret in _liveSecrets) { + if (secret.length >= 8 && out.contains(secret)) { + out = out.replaceAll(secret, ''); + } + } + return out.replaceAll(_bearerPattern, 'Bearer '); + } + + Iterable get _liveSecrets sync* { + final token = _token; + if (token != null) yield token; + final pending = _pendingPkce; + if (pending != null) { + yield pending.verifier; + yield pending.state; + } + final code = _lastCode; + if (code != null) yield code; + } + + void dispose() { + _linkSub?.cancel(); + _linkSub = null; + _client.close(); + } +} diff --git a/lib/services/portal_token_store.dart b/lib/services/portal_token_store.dart new file mode 100644 index 00000000..d521bcbc --- /dev/null +++ b/lib/services/portal_token_store.dart @@ -0,0 +1,204 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import '../utils/debug_logger_io.dart'; + +/// The half-finished PKCE handshake for one sign-in attempt. +/// +/// WHY this is PERSISTED rather than held in memory: on iOS the app is +/// routinely killed while the user is typing their portal password in Safari. +/// A memory-only verifier would fail the COMMON case, not an edge case. The +/// pair carries a 10-minute TTL and is burned after a single exchange attempt. +class PendingPkce { + final String verifier; + final String state; + final DateTime createdAt; + + const PendingPkce({ + required this.verifier, + required this.state, + required this.createdAt, + }); + + Map toJson() => { + 'verifier': verifier, + 'state': state, + 'created_at': createdAt.millisecondsSinceEpoch, + }; + + /// Returns null for anything unusable — a partially written or corrupted + /// blob must degrade to "no pending sign-in", never throw. + static PendingPkce? fromJson(Map json) { + final verifier = json['verifier']; + final state = json['state']; + final createdAt = json['created_at']; + if (verifier is! String || verifier.isEmpty) return null; + if (state is! String || state.isEmpty) return null; + if (createdAt is! int) return null; + return PendingPkce( + verifier: verifier, + state: state, + createdAt: DateTime.fromMillisecondsSinceEpoch(createdAt), + ); + } +} + +/// Storage for the MyMeshMapper portal bearer token and the in-flight PKCE +/// pair. Split behind an interface so tests never touch the platform keystore. +abstract class PortalTokenStore { + Future readToken(); + Future writeToken(String token); + Future deleteToken(); + + Future readPendingPkce(); + Future writePendingPkce(PendingPkce pending); + Future deletePendingPkce(); +} + +/// Keychain (iOS) / Keystore-backed EncryptedSharedPreferences (Android). +class SecureTokenStore implements PortalTokenStore { + static const String tokenKey = 'portal_app_token'; + static const String pendingPkceKey = 'portal_pending_pkce'; + + /// Named explicitly so the Android backup-exclusion rules can point at the + /// exact file (`res/xml/backup_rules.xml`, `res/xml/data_extraction_rules.xml`). + static const String androidPreferencesName = 'MeshMapperSecure'; + + static const IOSOptions _iosOptions = IOSOptions( + // The token must be readable by a background wardriving session on a + // locked phone, but must never ride an iCloud backup to another device. + accessibility: KeychainAccessibility.first_unlock_this_device, + ); + + static const AndroidOptions _androidOptions = AndroidOptions( + encryptedSharedPreferences: true, + sharedPreferencesName: androidPreferencesName, + resetOnError: true, + ); + + final FlutterSecureStorage _storage; + + SecureTokenStore({FlutterSecureStorage? storage}) + : _storage = storage ?? + const FlutterSecureStorage( + iOptions: _iosOptions, + aOptions: _androidOptions, + ); + + /// Reads never throw. + /// + /// Android auto-backup restores the EncryptedSharedPreferences ciphertext + /// without the hardware Keystore key that wrapped it, so the first read after + /// a device transfer raises BadPaddingException. Nuking the store and + /// reporting "signed out" is the only sane recovery — a throw here would take + /// down `AppStateProvider._initialize()` and the whole app with it. + Future _readSafely(String key) async { + try { + return await _storage.read( + key: key, + iOptions: _iosOptions, + aOptions: _androidOptions, + ); + } catch (e) { + debugWarn('[ACCOUNT] Secure storage read failed for "$key" ' + '(${e.runtimeType}) — resetting to signed-out'); + try { + await _storage.deleteAll( + iOptions: _iosOptions, + aOptions: _androidOptions, + ); + } catch (deleteError) { + debugError('[ACCOUNT] Secure storage reset failed: ' + '${deleteError.runtimeType}'); + } + return null; + } + } + + /// Failures are swallowed by design — a completed write is NOT proof the + /// value was persisted; callers learn that only from a later null read. + Future _writeSafely(String key, String value) async { + try { + await _storage.write( + key: key, + value: value, + iOptions: _iosOptions, + aOptions: _androidOptions, + ); + } catch (e) { + debugError( + '[ACCOUNT] Secure storage write failed for "$key" (${e.runtimeType})'); + } + } + + /// Failures are swallowed by design — a completed delete is NOT proof the + /// value is gone; sign-out must never hard-fail on a flaky keystore. + Future _deleteSafely(String key) async { + try { + await _storage.delete( + key: key, + iOptions: _iosOptions, + aOptions: _androidOptions, + ); + } catch (e) { + debugWarn('[ACCOUNT] Secure storage delete failed for "$key" ' + '(${e.runtimeType})'); + } + } + + @override + Future readToken() => _readSafely(tokenKey); + + @override + Future writeToken(String token) => _writeSafely(tokenKey, token); + + @override + Future deleteToken() => _deleteSafely(tokenKey); + + @override + Future readPendingPkce() async { + final raw = await _readSafely(pendingPkceKey); + if (raw == null || raw.isEmpty) return null; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return null; + return PendingPkce.fromJson(decoded); + } catch (e) { + debugWarn('[ACCOUNT] Pending PKCE blob unreadable — discarding'); + await _deleteSafely(pendingPkceKey); + return null; + } + } + + @override + Future writePendingPkce(PendingPkce pending) => + _writeSafely(pendingPkceKey, jsonEncode(pending.toJson())); + + @override + Future deletePendingPkce() => _deleteSafely(pendingPkceKey); +} + +/// Test double. Also useful as a null-object on platforms with no keystore. +class InMemoryTokenStore implements PortalTokenStore { + String? token; + PendingPkce? pending; + + @override + Future readToken() async => token; + + @override + Future writeToken(String value) async => token = value; + + @override + Future deleteToken() async => token = null; + + @override + Future readPendingPkce() async => pending; + + @override + Future writePendingPkce(PendingPkce value) async => pending = value; + + @override + Future deletePendingPkce() async => pending = null; +} diff --git a/lib/services/tile_cache_service.dart b/lib/services/tile_cache_service.dart new file mode 100644 index 00000000..ee9407ed --- /dev/null +++ b/lib/services/tile_cache_service.dart @@ -0,0 +1,76 @@ +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/services.dart'; + +import '../utils/debug_logger_io.dart'; + +/// Manages MapLibre's ambient tile cache — tiles opportunistically stored +/// during normal map use, separate from the explicitly downloaded offline +/// regions tracked by [OfflineMapService]. +/// +/// Routed through a platform MethodChannel on `meshmapper/tile_cache`: +/// - iOS handler: AppDelegate.swift (uses `MLNOfflineStorage.shared`) +/// - Android handler: MainActivity.kt (uses `OfflineManager.getInstance(ctx)`) +/// +/// Both handlers operate on MapLibre's global offline storage singleton, so +/// they don't depend on any map widget being alive. +/// +/// Size reporting reads the underlying cache database file directly. It +/// includes BOTH ambient cache and downloaded regions — MapLibre stores them +/// in the same SQLite file. +class TileCacheService { + TileCacheService._(); + static final TileCacheService instance = TileCacheService._(); + + static const _channel = MethodChannel('meshmapper/tile_cache'); + + /// Total size in bytes of the MapLibre cache database. Includes both + /// ambient cache and downloaded offline regions. Returns 0 on web or on + /// unexpected channel errors. + Future getCacheSizeBytes() async { + if (kIsWeb) return 0; + try { + final raw = await _channel.invokeMethod('getCacheSize'); + // iOS returns Int64 → Dart int; Android returns Long → Dart int. + if (raw is int) return raw; + if (raw is num) return raw.toInt(); + return 0; + } catch (e) { + debugWarn('[OFFLINE_MAP] Cache size read failed: $e'); + return 0; + } + } + + /// Per-region downloaded byte counts keyed by the Dart-side region ID that + /// maplibre_gl assigns. Regions missing from the result (e.g. because the + /// native store hasn't finished loading yet) should be treated as unknown, + /// not zero. Returns an empty map on web or unexpected channel errors. + Future> getRegionSizes() async { + if (kIsWeb) return const {}; + try { + final raw = await _channel.invokeMethod('getRegionSizes'); + if (raw is! Map) return const {}; + final result = {}; + raw.forEach((k, v) { + final id = k is int ? k : (k is num ? k.toInt() : int.tryParse('$k')); + final bytes = v is int ? v : (v is num ? v.toInt() : null); + if (id != null && bytes != null) result[id] = bytes; + }); + return result; + } catch (e) { + debugWarn('[OFFLINE_MAP] Region sizes read failed: $e'); + return const {}; + } + } + + Future clearAmbientCache() async { + if (kIsWeb) throw UnsupportedError('Not supported on web'); + await _channel.invokeMethod('clearAmbientCache'); + debugLog('[OFFLINE_MAP] Ambient cache cleared'); + } + + Future invalidateAmbientCache() async { + if (kIsWeb) throw UnsupportedError('Not supported on web'); + await _channel.invokeMethod('invalidateAmbientCache'); + debugLog('[OFFLINE_MAP] Ambient cache invalidated'); + } +} diff --git a/lib/services/transport/android_serial_service.dart b/lib/services/transport/android_serial_service.dart new file mode 100644 index 00000000..ec9af89d --- /dev/null +++ b/lib/services/transport/android_serial_service.dart @@ -0,0 +1,127 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; + +import '../../utils/debug_logger_io.dart'; +import '../bluetooth/bluetooth_service.dart'; +import 'stream_transport_base.dart'; + +/// USB Serial transport for Android via USB OTG. +/// +/// Uses a native Kotlin implementation (MeshMapperUsbService) that directly +/// accesses Android's USB API with proper CDC control transfers. +class AndroidSerialService extends StreamTransportBase { + final String deviceName; + final String productName; + + static const _methodChannel = MethodChannel('net.meshmapper.app/usb_serial'); + static const _eventChannel = + EventChannel('net.meshmapper.app/usb_serial/data'); + + StreamSubscription? _dataSubscription; + + AndroidSerialService({ + required this.deviceName, + required this.productName, + }); + + @override + Future openConnection() async { + debugLog('[CONN] USB Serial connecting to $productName'); + setConnecting(); + + try { + _dataSubscription = _eventChannel.receiveBroadcastStream().listen( + (data) { + if (data is Uint8List) { + onRawBytesReceived(data); + } else if (data is List) { + onRawBytesReceived(Uint8List.fromList(data.cast())); + } + }, + onError: (error) { + debugError('[CONN] USB Serial stream error: $error'); + setDisconnected(); + }, + onDone: () { + debugLog('[CONN] USB Serial stream closed'); + setDisconnected(); + }, + ); + + final connectResult = await _methodChannel.invokeMethod('connect', { + 'deviceName': deviceName, + 'baudRate': 115200, + }); + if (connectResult != null) { + debugLog( + '[CONN] USB device info: controlFound=${connectResult['controlFound']}, ' + 'interfaces=${connectResult['interfaceCount']}, ' + 'dataClass=0x${(connectResult['dataClass'] as int?)?.toRadixString(16) ?? '?'}, ' + 'inPacket=${connectResult['inMaxPacket']}, outPacket=${connectResult['outMaxPacket']}'); + } + + setConnected(DiscoveredDevice( + id: deviceName, + name: productName, + )); + + debugLog('[CONN] USB Serial connected: $productName'); + } catch (e) { + _dataSubscription?.cancel(); + _dataSubscription = null; + debugError('[CONN] USB Serial connection failed: $e'); + setError(); + rethrow; + } + } + + @override + Future writeRawBytes(Uint8List data) async { + await _methodChannel.invokeMethod('write', {'data': data}); + } + + @override + Future closeConnection() async { + try { + final diag = await _methodChannel.invokeMethod('readDiagnostics'); + if (diag != null) { + debugLog('[CONN] USB read loop: ' + 'reads=${diag['readAttempts']}, ' + 'bytesRx=${diag['bytesReceived']}, ' + 'events=${diag['eventsPosted']}, ' + 'sinkNull=${diag['sinkNullCount']}, ' + 'reading=${diag['isReading']}, ' + 'threadAlive=${diag['threadAlive']}, ' + 'sinkSet=${diag['sinkSet']}'); + } + } catch (e) { + debugWarn('[CONN] Read diagnostics unavailable: $e'); + } + _dataSubscription?.cancel(); + _dataSubscription = null; + try { + await _methodChannel.invokeMethod('disconnect'); + } catch (e) { + debugError('[CONN] USB Serial disconnect error: $e'); + } + debugLog('[CONN] USB Serial connection closed'); + } + + @override + void dispose() { + _dataSubscription?.cancel(); + _dataSubscription = null; + try { + _methodChannel.invokeMethod('disconnect'); + } catch (_) {} + super.dispose(); + } + + /// List available USB serial devices via native Android USB API. + static Future>> getAvailablePorts() async { + final result = await _methodChannel.invokeMethod('listDevices'); + if (result == null) return []; + return result.map((e) => Map.from(e as Map)).toList(); + } +} diff --git a/lib/services/transport/companion_transport.dart b/lib/services/transport/companion_transport.dart new file mode 100644 index 00000000..e3647ba5 --- /dev/null +++ b/lib/services/transport/companion_transport.dart @@ -0,0 +1,26 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import '../../models/connection_state.dart'; +import '../bluetooth/bluetooth_service.dart'; + +/// Transport-agnostic interface for MeshCore companion connections. +/// +/// Implemented by BLE (BluetoothService), TCP (TcpService), +/// and USB Serial (AndroidSerialService, WebSerialService). +/// MeshCoreConnection depends only on this interface. +abstract class CompanionTransport { + Stream get dataStream; + + Stream get connectionStream; + + ConnectionStatus get connectionStatus; + + DiscoveredDevice? get connectedDevice; + + Future write(Uint8List data); + + Future disconnect(); + + void dispose(); +} diff --git a/lib/services/transport/stream_frame_codec.dart b/lib/services/transport/stream_frame_codec.dart new file mode 100644 index 00000000..def7f7d5 --- /dev/null +++ b/lib/services/transport/stream_frame_codec.dart @@ -0,0 +1,128 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import '../../utils/debug_logger_io.dart'; + +/// Framing codec for TCP and USB Serial MeshCore companion connections. +/// +/// TCP and Serial use identical byte-stream framing: +/// - Outgoing (app→device): [0x3C][len_lo][len_hi][payload] +/// - Incoming (device→app): [0x3E][len_lo][len_hi][payload] +/// +/// BLE does NOT use this codec — GATT provides message boundaries natively. +class StreamFrameCodec { + static const int outgoingMarker = 0x3C; // '<' + static const int incomingMarker = 0x3E; // '>' + static const int headerSize = 3; + static const int maxTxPayload = 172; // firmware MAX_FRAME_SIZE + static const int maxRxPayload = 300; // defensive limit (matches meshcore_py) + + final _frameController = StreamController.broadcast(); + final _buffer = BytesBuilder(copy: true); + + Stream get frames => _frameController.stream; + + /// Encode an outgoing payload with the frame header. + static Uint8List encode(Uint8List payload) { + assert(payload.length <= maxTxPayload, + 'Payload exceeds max TX size: ${payload.length} > $maxTxPayload'); + final frame = Uint8List(payload.length + headerSize); + frame[0] = outgoingMarker; + frame[1] = payload.length & 0xFF; + frame[2] = (payload.length >> 8) & 0xFF; + frame.setRange(headerSize, frame.length, payload); + return frame; + } + + /// Feed incoming raw bytes for frame reassembly. + /// + /// Handles: junk byte discard, partial headers across chunks, + /// multiple frames in one chunk, zero-length and oversized frame rejection. + void addBytes(Uint8List data) { + if (data.isEmpty) return; + _buffer.add(data); + _processBuffer(); + } + + void _processBuffer() { + while (true) { + final bytes = _buffer.toBytes(); + if (bytes.length < headerSize) return; + + // Scan for incoming marker, discarding junk bytes before it + int markerIndex = -1; + for (int i = 0; i < bytes.length; i++) { + if (bytes[i] == incomingMarker) { + markerIndex = i; + break; + } + } + + if (markerIndex == -1) { + // No marker found — discard all bytes + _buffer.clear(); + return; + } + + if (markerIndex > 0) { + // Discard junk bytes before marker + _replaceBuffer(bytes.sublist(markerIndex)); + continue; + } + + // Marker is at position 0 — check if we have the full header + if (bytes.length < headerSize) return; + + final payloadLength = bytes[1] | (bytes[2] << 8); + + // Zero-length frame — skip the marker byte and rescan + if (payloadLength == 0) { + _replaceBuffer(bytes.sublist(1)); + continue; + } + + // Oversized frame — treat marker as junk, skip it and rescan + if (payloadLength > maxRxPayload) { + debugWarn( + '[CODEC] Oversized frame ($payloadLength bytes), skipping marker'); + _replaceBuffer(bytes.sublist(1)); + continue; + } + + final totalFrameSize = headerSize + payloadLength; + + // Not enough data yet for the full frame — wait for more + if (bytes.length < totalFrameSize) return; + + // Extract the payload (strip header) + final payload = + Uint8List.fromList(bytes.sublist(headerSize, totalFrameSize)); + + // Remove consumed bytes, keep remainder + if (bytes.length > totalFrameSize) { + _replaceBuffer(bytes.sublist(totalFrameSize)); + } else { + _buffer.clear(); + } + + // Emit the complete frame + if (!_frameController.isClosed) { + _frameController.add(payload); + } + } + } + + void _replaceBuffer(List remaining) { + _buffer.clear(); + _buffer.add(remaining); + } + + void reset() { + _buffer.clear(); + } + + void dispose() { + _buffer.clear(); + _frameController.close(); + } +} diff --git a/lib/services/transport/stream_transport_base.dart b/lib/services/transport/stream_transport_base.dart new file mode 100644 index 00000000..22ba548e --- /dev/null +++ b/lib/services/transport/stream_transport_base.dart @@ -0,0 +1,120 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import '../../models/connection_state.dart'; +import '../../utils/debug_logger_io.dart'; +import '../bluetooth/bluetooth_service.dart'; +import 'companion_transport.dart'; +import 'stream_frame_codec.dart'; + +/// Shared base class for TCP and USB Serial transports. +/// +/// Handles frame encoding/decoding via [StreamFrameCodec] and manages +/// connection state, data streams, and lifecycle. Subclasses implement +/// the raw byte I/O for their specific transport. +abstract class StreamTransportBase implements CompanionTransport { + final StreamFrameCodec _codec = StreamFrameCodec(); + final _connectionController = StreamController.broadcast(); + final _dataController = StreamController.broadcast(); + ConnectionStatus _status = ConnectionStatus.disconnected; + DiscoveredDevice? _device; + StreamSubscription? _codecSubscription; + + StreamTransportBase() { + _codecSubscription = _codec.frames.listen((payload) { + if (!_dataController.isClosed) { + _dataController.add(payload); + } + }); + } + + @override + Stream get dataStream => _dataController.stream; + + @override + Stream get connectionStream => _connectionController.stream; + + @override + ConnectionStatus get connectionStatus => _status; + + @override + DiscoveredDevice? get connectedDevice => _device; + + @override + Future write(Uint8List data) async { + if (_status != ConnectionStatus.connected) { + throw StateError('Cannot write: transport not connected'); + } + final framed = StreamFrameCodec.encode(data); + await writeRawBytes(framed); + } + + @override + Future disconnect() async { + if (_status == ConnectionStatus.disconnected) return; + try { + await closeConnection(); + } catch (e) { + debugError('[CONN] Error closing connection: $e'); + } + setDisconnected(); + } + + @override + void dispose() { + _codecSubscription?.cancel(); + _codec.dispose(); + _connectionController.close(); + _dataController.close(); + } + + /// Called when raw bytes arrive from the underlying transport. + /// Feeds them into the frame codec for reassembly. + void onRawBytesReceived(Uint8List data) { + _codec.addBytes(data); + } + + /// Update state to connecting and emit. + void setConnecting() { + _status = ConnectionStatus.connecting; + if (!_connectionController.isClosed) { + _connectionController.add(ConnectionStatus.connecting); + } + } + + /// Update state to error and emit. + void setError() { + _status = ConnectionStatus.error; + if (!_connectionController.isClosed) { + _connectionController.add(ConnectionStatus.error); + } + } + + /// Update state to connected and emit. + void setConnected(DiscoveredDevice device) { + _device = device; + _status = ConnectionStatus.connected; + if (!_connectionController.isClosed) { + _connectionController.add(ConnectionStatus.connected); + } + } + + /// Update state to disconnected and emit. + void setDisconnected() { + _device = null; + _codec.reset(); + _status = ConnectionStatus.disconnected; + if (!_connectionController.isClosed) { + _connectionController.add(ConnectionStatus.disconnected); + } + } + + /// Open the underlying byte stream (socket, serial port, etc). + Future openConnection(); + + /// Write raw (already framed) bytes to the underlying stream. + Future writeRawBytes(Uint8List data); + + /// Close the underlying byte stream. + Future closeConnection(); +} diff --git a/lib/services/transport/tcp_service.dart b/lib/services/transport/tcp_service.dart new file mode 100644 index 00000000..2c443d3f --- /dev/null +++ b/lib/services/transport/tcp_service.dart @@ -0,0 +1,165 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../utils/debug_logger_io.dart'; +import '../bluetooth/bluetooth_service.dart'; +import 'stream_transport_base.dart'; + +/// Saved TCP connection for quick reconnect. +class SavedTcpConnection { + final String id; + final String host; + final int port; + final String name; + final DateTime lastConnected; + + const SavedTcpConnection({ + required this.id, + required this.host, + required this.port, + required this.name, + required this.lastConnected, + }); + + Map toJson() => { + 'id': id, + 'host': host, + 'port': port, + 'name': name, + 'lastConnected': lastConnected.toIso8601String(), + }; + + factory SavedTcpConnection.fromJson(Map json) { + return SavedTcpConnection( + id: json['id'] as String, + host: json['host'] as String, + port: json['port'] as int, + name: json['name'] as String, + lastConnected: DateTime.parse(json['lastConnected'] as String), + ); + } +} + +/// TCP transport for MeshCore companion connections. +/// +/// Connects to a MeshCore device via raw TCP socket (default port 5000). +/// Available on Android, iOS, and Desktop (NOT web — browsers cannot +/// open raw TCP sockets). +class TcpService extends StreamTransportBase { + final String host; + final int port; + + Socket? _socket; + StreamSubscription? _socketSubscription; + + TcpService({required this.host, this.port = 5000}); + + @override + Future openConnection() async { + debugLog('[CONN] TCP connecting to $host:$port'); + setConnecting(); + + try { + _socket = await Socket.connect(host, port, + timeout: const Duration(seconds: 10)); + _socket!.setOption(SocketOption.tcpNoDelay, true); + debugLog('[CONN] TCP connected to $host:$port'); + + setConnected(DiscoveredDevice( + id: '$host:$port', + name: 'TCP $host:$port', + )); + + _socketSubscription = _socket!.listen( + (data) => onRawBytesReceived(Uint8List.fromList(data)), + onError: (error) { + debugError('[CONN] TCP socket error: $error'); + setDisconnected(); + }, + onDone: () { + debugLog('[CONN] TCP socket closed by remote'); + setDisconnected(); + }, + ); + } catch (e) { + debugError('[CONN] TCP connection failed: $e'); + setError(); + rethrow; + } + } + + @override + Future writeRawBytes(Uint8List data) async { + _socket?.add(data); + } + + @override + Future closeConnection() async { + _socketSubscription?.cancel(); + _socketSubscription = null; + _socket?.destroy(); + _socket = null; + debugLog('[CONN] TCP connection closed'); + } + + @override + void dispose() { + _socketSubscription?.cancel(); + _socket?.destroy(); + super.dispose(); + } + + // --- Saved connections persistence --- + + static const _prefsKey = 'saved_tcp_connections'; + + static Future> getSavedConnections() async { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString(_prefsKey); + if (jsonString == null) return []; + + try { + final list = jsonDecode(jsonString) as List; + return list + .map((e) => SavedTcpConnection.fromJson(e as Map)) + .toList() + ..sort((a, b) => b.lastConnected.compareTo(a.lastConnected)); + } catch (e) { + debugError('[CONN] Failed to load saved TCP connections: $e'); + return []; + } + } + + static Future saveConnection(String host, int port, String name) async { + final prefs = await SharedPreferences.getInstance(); + final connections = await getSavedConnections(); + + final id = '$host:$port'; + connections.removeWhere((c) => c.id == id); + connections.insert( + 0, + SavedTcpConnection( + id: id, + host: host, + port: port, + name: name.isEmpty ? 'TCP $host:$port' : name, + lastConnected: DateTime.now(), + )); + + final jsonString = jsonEncode(connections.map((c) => c.toJson()).toList()); + await prefs.setString(_prefsKey, jsonString); + } + + static Future deleteConnection(String id) async { + final prefs = await SharedPreferences.getInstance(); + final connections = await getSavedConnections(); + connections.removeWhere((c) => c.id == id); + + final jsonString = jsonEncode(connections.map((c) => c.toJson()).toList()); + await prefs.setString(_prefsKey, jsonString); + } +} diff --git a/lib/services/transport/web_serial_factory.dart b/lib/services/transport/web_serial_factory.dart new file mode 100644 index 00000000..2f9d138b --- /dev/null +++ b/lib/services/transport/web_serial_factory.dart @@ -0,0 +1,2 @@ +export 'web_serial_factory_stub.dart' + if (dart.library.js_interop) 'web_serial_factory_web.dart'; diff --git a/lib/services/transport/web_serial_factory_stub.dart b/lib/services/transport/web_serial_factory_stub.dart new file mode 100644 index 00000000..514fcf99 --- /dev/null +++ b/lib/services/transport/web_serial_factory_stub.dart @@ -0,0 +1,4 @@ +import 'companion_transport.dart'; + +Future openWebSerialTransport() => + throw UnsupportedError('Web Serial API is only available on web'); diff --git a/lib/services/transport/web_serial_factory_web.dart b/lib/services/transport/web_serial_factory_web.dart new file mode 100644 index 00000000..0ccc7db3 --- /dev/null +++ b/lib/services/transport/web_serial_factory_web.dart @@ -0,0 +1,8 @@ +import 'companion_transport.dart'; +import 'web_serial_service.dart'; + +Future openWebSerialTransport() async { + final service = WebSerialService(); + await service.openConnection(); + return service; +} diff --git a/lib/services/transport/web_serial_service.dart b/lib/services/transport/web_serial_service.dart new file mode 100644 index 00000000..161b046c --- /dev/null +++ b/lib/services/transport/web_serial_service.dart @@ -0,0 +1,128 @@ +import 'dart:async'; +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:web/web.dart' as web; + +import '../../utils/debug_logger_io.dart'; +import '../bluetooth/bluetooth_service.dart'; +import 'stream_transport_base.dart'; + +// Web Serial API JS interop bindings + +@JS('navigator.serial') +external JSObject? get _jsNavigatorSerial; + +extension type SerialApi._(JSObject _) implements JSObject { + external JSPromise requestPort([JSObject? options]); +} + +extension type SerialPort._(JSObject _) implements JSObject { + external JSPromise open(JSObject options); + external JSPromise close(); + external web.ReadableStream get readable; + external web.WritableStream get writable; +} + +extension type SerialOptions._(JSObject _) implements JSObject { + external factory SerialOptions({int baudRate}); +} + +/// USB Serial transport for web browsers via the Web Serial API. +/// +/// Uses `navigator.serial` (Chrome/Edge only). The browser shows a native +/// port picker dialog when [openConnection] is called. +class WebSerialService extends StreamTransportBase { + SerialPort? _port; + web.ReadableStreamDefaultReader? _reader; + bool _reading = false; + + @override + Future openConnection() async { + debugLog('[CONN] Web Serial: requesting port'); + setConnecting(); + + try { + final serial = _jsNavigatorSerial; + if (serial == null) { + throw UnsupportedError('Web Serial API not available in this browser'); + } + + final serialApi = SerialApi._(serial); + final portObj = await serialApi.requestPort().toDart; + _port = SerialPort._(portObj); + await _port!.open(SerialOptions(baudRate: 115200)).toDart; + + setConnected(const DiscoveredDevice( + id: 'webserial', + name: 'USB Serial (Web)', + )); + + _startReadLoop(); + debugLog('[CONN] Web Serial connected'); + } catch (e) { + debugError('[CONN] Web Serial connection failed: $e'); + setError(); + rethrow; + } + } + + void _startReadLoop() async { + _reader = _port!.readable.getReader() as web.ReadableStreamDefaultReader; + _reading = true; + + try { + while (_reading) { + final result = await _reader!.read().toDart; + if (result.done) break; + + final value = result.value; + if (value != null) { + final jsArray = value as JSUint8Array; + final bytes = jsArray.toDart; + onRawBytesReceived(bytes); + } + } + } catch (e) { + if (_reading) { + debugError('[CONN] Web Serial read error: $e'); + setDisconnected(); + } + } + } + + @override + Future writeRawBytes(Uint8List data) async { + final writer = _port!.writable.getWriter(); + try { + final jsData = data.toJS; + await writer.write(jsData).toDart; + } finally { + writer.releaseLock(); + } + } + + @override + Future closeConnection() async { + _reading = false; + try { + await _reader?.cancel().toDart; + } catch (_) {} + _reader = null; + try { + await _port?.close().toDart; + } catch (_) {} + _port = null; + debugLog('[CONN] Web Serial connection closed'); + } + + @override + void dispose() { + _reading = false; + _reader?.cancel(); + super.dispose(); + } + + /// Check if Web Serial API is available in this browser. + static bool get isAvailable => _jsNavigatorSerial != null; +} diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart new file mode 100644 index 00000000..94ce7304 --- /dev/null +++ b/lib/services/watch/watch_bridge_service.dart @@ -0,0 +1,620 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../../utils/debug_logger_io.dart'; +import '../external_commands/external_session_commands.dart'; +import '../external_surfaces/external_surface_publisher.dart'; +import 'watch_models.dart'; + +typedef WatchSnapshotBuilder = WatchSnapshot? Function(); +typedef WatchUrgencyKeyBuilder = String Function(); + +/// Decides whether a wrist command may begin. Returns null when admitted, or a +/// reason when refused; admitted work continues independently of this reply. +/// Production handlers must decide synchronously; FutureOr keeps existing +/// bridge fakes source-compatible without putting the real path behind a wait. +typedef WatchCommandHandler = FutureOr Function(WatchCommand command); +typedef WatchCommandRefusalHandler = void Function(String reason); +typedef WatchAvailabilityHandler = void Function(bool available); + +/// Read-only evidence from both sides of the phone-to-watch bridge. +/// +/// These timestamps intentionally do not share the transport's throttle and +/// dedupe fields. Those fields are cleared when WatchConnectivity changes +/// state, while a diagnostic must retain the last known-good send across the +/// exact outage that caused the state change. +@immutable +class WatchDiagnosticStatus { + const WatchDiagnosticStatus({ + this.supported = false, + this.paired = false, + this.installed = false, + this.reachable = false, + this.activated = false, + this.canSync = false, + this.lastSuccessfulSendAt, + this.lastAvailabilityChangedAt, + this.lastSendDelivered, + }); + + final bool supported; + final bool paired; + final bool installed; + final bool reachable; + final bool activated; + final bool canSync; + final DateTime? lastSuccessfulSendAt; + final DateTime? lastAvailabilityChangedAt; + final bool? lastSendDelivered; + + List get failingSyncConditions => [ + if (!activated) 'activated', + if (!paired) 'paired', + if (!installed) 'installed', + ]; +} + +/// Owns the Flutter↔WatchConnectivity bridge and coalesces noisy app state. +/// +/// Deliberately mirrors [LiveActivityService]'s shape — fingerprint dedupe, +/// urgency bypass, minimum non-urgent interval — because that pattern is +/// already proven in this app. The one addition is a movement gate: GPS +/// updates arrive continuously while driving, and forwarding every one would +/// flatten the watch battery for sub-pixel map changes. +class WatchBridgeService { + WatchBridgeService({ + @visibleForTesting MethodChannel? channel, + @visibleForTesting Duration debounceDelay = defaultDebounceDelay, + @visibleForTesting + Duration minimumNonUrgentInterval = defaultMinimumNonUrgentInterval, + @visibleForTesting + Duration mapGeoClaimFreshFor = defaultMapGeoClaimFreshFor, + }) : _channel = channel ?? const MethodChannel(_channelName), + _mapGeoClaimFreshFor = mapGeoClaimFreshFor { + _publisher = ExternalSurfacePublisher>( + debounceDelay: debounceDelay, + minimumNonUrgentInterval: minimumNonUrgentInterval, + isEnabled: () => canSync, + preflightPolicy: + ExternalSurfacePreflightPolicy.throttleAgainstPublishedUrgency, + payloadBuilder: (snapshot) => snapshot.toMap(), + fingerprintBuilder: (payload) { + final fingerprint = Map.from(payload) + ..remove('updatedAtMs'); + return jsonEncode(fingerprint); + }, + urgencyKeyBuilder: (snapshot) => snapshot.urgencyKey, + publish: _publishSnapshot, + clear: _clear, + candidateGate: _passesMovementGate, + onQueueError: (error) { + debugError('[WATCH] Update queue failed: $error'); + }, + ); + } + + static const String _channelName = 'meshmapper/watch'; + + /// How many wrist commands may wait for Dart to start listening. + /// + /// A queued command can wake a phone app that was not running, and + /// `didReceiveUserInfo` fires as soon as WatchConnectivity has it — which can + /// be well before [attachCommandHandler] runs, since that happens partway + /// through the provider's asynchronous initialization. `transferUserInfo` + /// hands each command over exactly once, so anything dropped in that window + /// is gone: the wrist shows a spinner, times out after ten seconds, and says + /// nothing about why. + /// + /// Flutter already buffers platform messages sent before a handler exists, + /// but only one deep by default, so a wearer who tapped twice lost the first + /// tap. Eight is well past any plausible burst — the wearer has one Start and + /// one Ping button — while still bounded, because these are intents that go + /// through the age window and the ID cache on arrival, not state to replay. + static const int _commandQueueDepth = 8; + + /// Reserve that room. Must run before the first command can arrive, which in + /// practice means the top of `main()`. + /// + /// This narrows the window rather than closing it. Messages that arrive + /// before Dart's entrypoint executes at all are still governed by the default + /// depth of one — resizing upward keeps whatever is already queued, so that + /// one survives, but a burst landing in the gap between engine start and this + /// call cannot. Closing it completely means buffering natively and handshaking + /// a ready signal, which is a lot of moving parts for a smaller window than + /// the one this covers. + static void reserveCommandQueue() { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS) return; + ServicesBinding.instance.channelBuffers + .resize(_channelName, _commandQueueDepth); + } + + /// The three durations a test may shorten. + /// + /// Without a seam here every throttle assertion had to wait out the real + /// interval, so one group of them spent ~35 s of wall clock and raced a live + /// timer on loaded CI — and the ten-minute lease expiry simply could not be + /// covered at all. [LiveActivityService] already took its intervals this way; + /// this closes the gap between the two. + /// + /// Deliberately *not* injectable: [maximumExternalCommandAge] and + /// [externalCommandFutureTolerance] are compared against timestamps a test + /// can choose freely, so shortening them would buy nothing and would let a + /// test pass against a window the wire does not actually use. + static const Duration defaultDebounceDelay = Duration(milliseconds: 200); + static const Duration defaultMinimumNonUrgentInterval = Duration(seconds: 2); + static const Duration defaultMapGeoClaimFreshFor = Duration(minutes: 10); + + final MethodChannel _channel; + final Duration _mapGeoClaimFreshFor; + late final ExternalSurfacePublisher> + _publisher; + + WatchCommandHandler? _commandHandler; + WatchCommandRefusalHandler? _commandRefusalHandler; + WatchAvailabilityHandler? _availabilityHandler; + + /// The delivered payload with the fix removed, plus the fix that went with + /// it. Together they answer "did anything but our position change?", which is + /// what the movement gate needs and the whole-payload fingerprint cannot say. + String? _lastPayloadWithoutFix; + ({double lat, double lon})? _lastSentFix; + bool _disposed = false; + + /// A refresh the watch asked for, which dedupe must not answer with silence. + /// + /// The phone only forgets its delivered-payload fingerprint when + /// WatchConnectivity reports a state change, and relaunching the watch app is + /// not one: pairing and installation are unchanged, so the phone still + /// believes the watch holds this exact payload. It does — but only as a + /// retained context whose age is now shown honestly, which is precisely the + /// state the wearer is asking to be rid of. Answering "nothing changed" with + /// nothing at all leaves that state stale until something unrelated moves. + /// + /// Survives a deferred flush so the radio throttle can still delay the + /// refresh, and is cleared only once a payload is actually delivered. + bool _canSync = false; + Map? _lastNativeStatus; + DateTime? _lastSuccessfulSendAt; + DateTime? _lastAvailabilityChangedAt; + bool? _lastSendDelivered; + final ValueNotifier _diagnostics = + ValueNotifier(const WatchDiagnosticStatus()); + DateTime? _mapGeoSuppressedAt; + double? _lastMapGeoClaimIssuedAtMs; + + /// Outcome of every command already handled, keyed by ID, so redelivery + /// cannot fire a second transmit — and is answered with what actually + /// happened rather than a blanket acceptance. Null means accepted. + final Map _handledCommandOutcomes = {}; + + bool get isSupportedPlatform => + !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; + bool get canSync => isSupportedPlatform && _canSync; + ValueListenable get diagnostics => _diagnostics; + + /// Whether the next payload must carry map-only geography. + /// + /// Suppression is leased rather than latched. If the wrist stops renewing + /// its claim, the phone returns to full geo on the next build; excess bytes + /// are safer than leaving a newly-visible map blank. + bool get shouldIncludeMapGeo { + final suppressedAt = _mapGeoSuppressedAt; + if (suppressedAt == null) return true; + return DateTime.now().difference(suppressedAt) >= _mapGeoClaimFreshFor; + } + + /// Wire up the inbound command path. Safe to call more than once. + void attachCommandHandler( + WatchCommandHandler handler, { + WatchCommandRefusalHandler? onRefusal, + WatchAvailabilityHandler? onAvailabilityChanged, + }) { + _commandHandler = handler; + _commandRefusalHandler = onRefusal; + _availabilityHandler = onAvailabilityChanged; + if (!isSupportedPlatform) return; + _channel.setMethodCallHandler(_handleNativeCall); + unawaited(_refreshAvailability()); + } + + Future _handleNativeCall(MethodCall call) async { + if (call.method == 'availabilityChanged') { + // Only a push that says native dropped its own application-context cache + // invalidates ours. Reachability flips on every wrist raise and lower, + // and treating those as cache invalidation forced a full + // `updateApplicationContext` resend per glance and silently voided the + // map-geo lease — the two costs this bridge exists to avoid. + final args = call.arguments; + final nativeCacheCleared = + args is Map && args['nativeCacheCleared'] == true; + _applyAvailability(args, refreshNativeState: nativeCacheCleared); + return null; + } + if (call.method != 'command') return null; + + final args = call.arguments; + if (args is! Map) return {'accepted': false, 'reason': 'Malformed command'}; + + final id = args['id'] as String?; + final rawKind = args['kind'] as String?; + if (id == null || rawKind == null) { + return {'accepted': false, 'reason': 'Malformed command'}; + } + + // WatchConnectivity redelivers; a duplicate must not transmit twice. + // + // Answered with the outcome recorded the first time. Replying "accepted" + // to the redelivery of something this bridge refused would describe a + // transmit that never happened, and be wrong in exactly the case the + // caller most needs the truth. + if (_handledCommandOutcomes.containsKey(id)) { + final refusal = _handledCommandOutcomes[id]; + return {'id': id, 'accepted': refusal == null, 'reason': refusal}; + } + + final kind = WatchCommandKind.fromWire(rawKind); + if (kind == null) { + return {'id': id, 'accepted': false, 'reason': 'Unsupported command'}; + } + + final handler = _commandHandler; + if (handler == null) { + return {'id': id, 'accepted': false, 'reason': 'App not ready'}; + } + + _rememberCommand(id); + + final rawIssuedAtMs = args['issuedAtMs']; + final issuedAtMs = rawIssuedAtMs is num ? rawIssuedAtMs.toDouble() : null; + + // The two devices do not share a clock. `issuedAtMs` is stamped in the + // watch's, and the watch tells us how far that runs from ours when it has + // been able to measure it — from a live `sendMessage`, whose transit is + // milliseconds. Correcting by it means the age below is a real elapsed + // time rather than an elapsed time plus however far the clocks disagree, + // which used to turn any skew past the future tolerance into "every command + // refused" instead of a slightly wider margin. + // + // Absent from older watch builds, and from a watch that has not yet seen a + // live delivery. Zero then, which is exactly the previous behaviour. + final rawClockOffsetMs = args['clockOffsetMs']; + final clockOffsetMs = + rawClockOffsetMs is num ? rawClockOffsetMs.toDouble() : 0.0; + final ageMs = issuedAtMs == null + ? null + : DateTime.now().millisecondsSinceEpoch - (issuedAtMs + clockOffsetMs); + final requestedMapGeo = args['mapGeoNeeded']; + final mapGeoNeeded = requestedMapGeo is bool ? requestedMapGeo : null; + final freshMapGeoSuppression = ageMs != null && + ageMs >= -externalCommandFutureTolerance.inMilliseconds && + ageMs <= maximumExternalCommandAge.inMilliseconds; + final latestMapGeoClaim = _lastMapGeoClaimIssuedAtMs; + final suppressionIsNewest = issuedAtMs != null && + (latestMapGeoClaim == null || issuedAtMs >= latestMapGeoClaim); + final effectiveMapGeoNeeded = mapGeoNeeded == false && + (!freshMapGeoSuppression || !suppressionIsNewest) + ? null + : mapGeoNeeded; + if (kind == WatchCommandKind.requestSnapshot && + effectiveMapGeoNeeded != null) { + // A stale or out-of-order false could arrive after the wrist returned to + // the map. Ignore it silently; true is always safe because it only + // restores detail. Never move the ordering watermark backwards when an + // older true is accepted for that conservative reason. + _mapGeoSuppressedAt = effectiveMapGeoNeeded ? null : DateTime.now(); + if (issuedAtMs != null && + (latestMapGeoClaim == null || issuedAtMs >= latestMapGeoClaim)) { + _lastMapGeoClaimIssuedAtMs = issuedAtMs; + } + } + // Stopping is exempt for the same reason as requestSnapshot: the window + // exists so a late command cannot put a transmit on air from the wrong + // place, and a stop takes the radio *off* air. Refusing a queued stop that + // was slow to arrive — the phone out of range, the watch suspended mid + // transfer — leaves the session transmitting after the wearer asked it to + // end, which is the failure the window is supposed to prevent. + const ageExemptKinds = { + WatchCommandKind.requestSnapshot, + WatchCommandKind.stopSession, + }; + if (!ageExemptKinds.contains(kind) && issuedAtMs != null) { + // Both bounds matter. Too old is the obvious case; too far in the future + // is the same bug wearing a disguise, because a watch clock running fast + // makes `ageMs` negative and would otherwise extend the window by however + // far the clocks disagree. The tolerance matches the map-geo check above. + if (ageMs! > maximumExternalCommandAge.inMilliseconds || + ageMs < -externalCommandFutureTolerance.inMilliseconds) { + final reason = externalCommandExpiredReason; + // This window is about correctness, not queue housekeeping: executing + // a transmit after the vehicle has moved attributes it to the wrong + // place. Missing timestamps remain accepted for older watch builds. + _rememberCommand(id, refusal: reason); + _commandRefusalHandler?.call(reason); + return {'id': id, 'accepted': false, 'reason': reason}; + } + } + + try { + // This is admission, not completion. Keeping the handler synchronous is + // what makes the MethodChannel response fit inside WatchConnectivity's + // short reply window; the admitted action reports its later outcome via + // normal snapshots and one-shot cues. + final admission = handler(WatchCommand( + kind: kind, + id: id, + issuedAt: issuedAtMs == null + ? null + : DateTime.fromMillisecondsSinceEpoch( + (issuedAtMs + clockOffsetMs).round(), + ), + mode: args['mode'] as String?, + mapGeoNeeded: effectiveMapGeoNeeded, + forceRefresh: args['forceRefresh'] == true, + sessionId: args['sessionId'] as String?, + )); + final refusal = + admission is Future ? await admission : admission; + _rememberCommand(id, refusal: refusal); + if (refusal != null) { + _commandRefusalHandler?.call(refusal); + // An untimestamped command cannot be aged, so it is also the one whose + // sender may legitimately retry it under changed conditions. Queued, + // timestamped commands must stay remembered: redelivery after + // conditions change must never turn yesterday's tap into a transmit. + if (issuedAtMs == null) _handledCommandOutcomes.remove(id); + } + return {'id': id, 'accepted': refusal == null, 'reason': refusal}; + } catch (error) { + debugError('[WATCH] Command $rawKind failed: $error'); + const reason = 'Command failed'; + _rememberCommand(id, refusal: reason); + _commandRefusalHandler?.call(reason); + return {'id': id, 'accepted': false, 'reason': reason}; + } + } + + Future _refreshAvailability() async { + try { + _applyAvailability(await _channel.invokeMethod('status')); + } on MissingPluginException { + // Expected on non-iOS test hosts and older generated projects. + } on PlatformException catch (error) { + debugError('[WATCH] Status failed: ${error.code}: ${error.message}'); + } + } + + /// Re-reads the local WCSession properties for the diagnostic surface. + /// This does not send a snapshot or bypass the existing availability gate. + Future refreshAvailability() => _refreshAvailability(); + + void _applyAvailability( + Object? raw, { + bool refreshNativeState = false, + }) { + if (raw is! Map) return; + final status = { + 'supported': raw['supported'] == true, + 'paired': raw['paired'] == true, + 'installed': raw['installed'] == true, + 'reachable': raw['reachable'] == true, + 'activated': raw['activated'] == true, + }; + final available = + status['activated']! && status['paired']! && status['installed']!; + final availabilityChanged = available != _canSync; + final statusChanged = !mapEquals(_lastNativeStatus, status); + + if (availabilityChanged || refreshNativeState) _canSync = available; + if (statusChanged) { + _lastNativeStatus = Map.unmodifiable(status); + _lastAvailabilityChangedAt = DateTime.now(); + _publishDiagnostics(); + // This is deliberately tied to a changed native status map. Snapshot + // scheduling and explicit refreshes can call this path frequently, but + // repeated state adds no evidence and would hide the useful transition. + debugLog( + '[WATCH] Availability changed: status=$status canSync=$_canSync'); + } + if (!availabilityChanged && !refreshNativeState) return; + + // Native forgets its application-context cache when WatchConnectivity + // reports a *watch-state* change. Forget ours on the same notification even + // when availability remains true, or an installed replacement watch could + // wait forever for state whose fingerprint Dart still considers delivered. + if (!available || refreshNativeState) { + _mapGeoSuppressedAt = null; + _lastMapGeoClaimIssuedAtMs = null; + _publisher.resetPublishedState(); + _lastPayloadWithoutFix = null; + _lastSentFix = null; + } + _availabilityHandler?.call(available); + } + + void _publishDiagnostics() { + if (_disposed) return; + final status = _lastNativeStatus; + _diagnostics.value = WatchDiagnosticStatus( + supported: status?['supported'] ?? false, + paired: status?['paired'] ?? false, + installed: status?['installed'] ?? false, + reachable: status?['reachable'] ?? false, + activated: status?['activated'] ?? false, + canSync: canSync, + lastSuccessfulSendAt: _lastSuccessfulSendAt, + lastAvailabilityChangedAt: _lastAvailabilityChangedAt, + lastSendDelivered: _lastSendDelivered, + ); + } + + /// Record a command and, once known, what it resolved to. + /// + /// Called before admission so a re-entrant redelivery cannot slip past the + /// dedupe, then again with the outcome. Updating an existing key leaves the + /// insertion order alone, so the bound below still evicts the oldest. + void _rememberCommand(String id, {String? refusal}) { + _handledCommandOutcomes[id] = refusal; + // Unbounded growth would leak across a long session. + if (_handledCommandOutcomes.length > 64) { + _handledCommandOutcomes.remove(_handledCommandOutcomes.keys.first); + } + } + + /// - Parameter forceDelivery: send even when the payload is byte-identical to + /// the last delivered one. Reserved for a refresh the watch explicitly + /// asked for; ordinary immediate updates stay deduplicatable, because most + /// of them are urgent precisely because something did change. + void schedule( + WatchSnapshotBuilder snapshotBuilder, { + required WatchUrgencyKeyBuilder urgencyKeyBuilder, + bool immediate = false, + bool forceDelivery = false, + }) { + if (_disposed || !canSync) return; + _publisher.schedule( + snapshotBuilder, + preflightKeyBuilder: urgencyKeyBuilder, + immediate: immediate, + forceDelivery: forceDelivery, + ); + } + + bool _passesMovementGate( + ExternalSurfacePublication> publication, + ) { + // The movement gate. A stationary GPS jitters by a few metres for as long + // as the phone is switched on, and forwarding that would keep the watch + // radio busy for a puck that never visibly moves. + // + // It is deliberately expressed as "nothing but the fix changed, and the fix + // did not move far enough" rather than as a stale position in the payload, + // which is where it used to live. Those are the same suppression and a very + // different packet: a new ping defeats the dedupe by itself, and the older + // arrangement then sent that ping alongside a puck up to 15 m behind it — + // visibly so once the watch's zoom floor reached ~22 m of latitude. + final fingerprint = Map.from(publication.payload) + ..remove('updatedAtMs'); + final fix = _fixOf(fingerprint); + final withoutFix = _encodeWithoutFix(fingerprint); + if (!publication.forceDelivery && + withoutFix == _lastPayloadWithoutFix && + !_fixMovedEnough(fix)) { + return false; + } + return true; + } + + Future _publishSnapshot( + ExternalSurfacePublication> publication, + ) async { + try { + final delivered = await _channel.invokeMethod('sync', { + 'payload': publication.payload, + 'urgent': publication.urgent, + }); + if (delivered != true) { + _lastSendDelivered = false; + _publishDiagnostics(); + // Native can lose availability between status and send. Do not cache + // a payload it refused. Re-query rather than guessing which condition + // failed, so a transient context error cannot permanently close the + // gate while the watch is actually still installed. + await _refreshAvailability(); + return const ExternalSurfacePublishResult.rejected(); + } + // Anchored to what the watch actually received, so a refused or dropped + // send cannot quietly consume the wearer's next 15 m of movement. + final fingerprint = Map.from(publication.payload) + ..remove('updatedAtMs'); + _lastPayloadWithoutFix = _encodeWithoutFix(fingerprint); + _lastSentFix = _fixOf(fingerprint); + final sentAt = DateTime.now(); + _lastSuccessfulSendAt = sentAt; + _lastSendDelivered = true; + _publishDiagnostics(); + return const ExternalSurfacePublishResult.published(); + } on MissingPluginException { + // Expected on non-iOS hosts and in tests. + return const ExternalSurfacePublishResult.rejected(); + } on PlatformException catch (error) { + debugError('[WATCH] Sync failed: ${error.code}: ${error.message}'); + return const ExternalSurfacePublishResult.rejected(); + } catch (error) { + debugError('[WATCH] Unexpected sync failure: $error'); + return const ExternalSurfacePublishResult.rejected(); + } + } + + Future _clear({required bool immediate}) async { + try { + await _channel.invokeMethod('clear'); + } on MissingPluginException { + // Expected on non-iOS hosts and in tests. + } on PlatformException catch (error) { + debugError('[WATCH] Clear failed: ${error.code}: ${error.message}'); + } catch (error) { + debugError('[WATCH] Unexpected clear failure: $error'); + } finally { + _lastPayloadWithoutFix = null; + _lastSentFix = null; + } + } + + /// Whether the fix has moved far enough to be worth a send on its own. + /// + /// Measured against the fix the watch last *received*, not the last one the + /// phone computed, so a parked phone's jitter can never accumulate its way + /// past the threshold one sub-threshold step at a time. + bool _fixMovedEnough(({double lat, double lon})? fix) { + final last = _lastSentFix; + if (fix == null || last == null) return true; + return WatchWire.movedEnough( + lastLat: last.lat, + lastLon: last.lon, + lat: fix.lat, + lon: fix.lon, + ); + } + + static Map? _geoOf(Map fingerprint) { + final geo = fingerprint['geo']; + return geo is Map ? Map.from(geo) : null; + } + + static ({double lat, double lon})? _fixOf(Map fingerprint) { + final you = _geoOf(fingerprint)?['you']; + if (you is! Map) return null; + final lat = you['lat']; + final lon = you['lon']; + if (lat is! num || lon is! num) return null; + return (lat: lat.toDouble(), lon: lon.toDouble()); + } + + /// The payload with the wearer's position taken out, so two of them can be + /// compared for "did anything else change?". + /// + /// The whole `you` object goes, not just its coordinates: heading, accuracy + /// and fix time all drift on a phone that has not moved, and treating any of + /// them as a reason to send would defeat the gate they are travelling with. + static String _encodeWithoutFix(Map fingerprint) { + final geo = _geoOf(fingerprint); + if (geo == null) return jsonEncode(fingerprint); + return jsonEncode( + Map.from(fingerprint)..['geo'] = (geo..remove('you')), + ); + } + + void dispose() { + _disposed = true; + _publisher.dispose(); + _commandHandler = null; + _commandRefusalHandler = null; + _availabilityHandler = null; + _mapGeoSuppressedAt = null; + _lastMapGeoClaimIssuedAtMs = null; + _diagnostics.dispose(); + } +} diff --git a/lib/services/watch/watch_color.dart b/lib/services/watch/watch_color.dart new file mode 100644 index 00000000..f57ff876 --- /dev/null +++ b/lib/services/watch/watch_color.dart @@ -0,0 +1,3 @@ +import '../external_surfaces/external_surface_color.dart'; + +typedef WatchColor = ExternalSurfaceColor; diff --git a/lib/services/watch/watch_geo_builder.dart b/lib/services/watch/watch_geo_builder.dart new file mode 100644 index 00000000..750b28c4 --- /dev/null +++ b/lib/services/watch/watch_geo_builder.dart @@ -0,0 +1,3 @@ +import '../external_surfaces/geo/external_surface_geo_builder.dart'; + +typedef WatchGeoBuilder = ExternalSurfaceGeoBuilder; diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart new file mode 100644 index 00000000..40a0fb19 --- /dev/null +++ b/lib/services/watch/watch_models.dart @@ -0,0 +1,576 @@ +import '../external_commands/external_command_models.dart'; +import '../external_surfaces/geo/external_surface_geo_models.dart'; +import '../live_activity/live_activity_models.dart'; +import 'watch_color.dart'; + +export 'watch_color.dart'; + +/// Wire contract for the watchOS companion. +/// +/// The Swift mirror lives in `ios/Shared/MeshMapperWatchPayload.swift` and is +/// compiled into both Runner and the watch target. This file and that one are +/// a matched pair — change one, change the other, and update the golden +/// fixtures in `test/services/watch/`. +/// +/// [LiveActivitySnapshot] is reused verbatim for the session core rather than +/// re-deriving phase/counter semantics, which is the expensive and bug-prone +/// part. This snapshot composes it with the geography, controls, and haptic +/// cue the Live Activity has no use for. +class WatchWire { + WatchWire._(); + + /// Bump when a field changes meaning or is removed. The watch refuses + /// payloads it doesn't understand rather than rendering something wrong. + /// + /// v2: heard nodes mirror the app's "Top Heard" map overlay — hex ID and + /// ping-type colour — instead of the richer per-echo data. Hop counts are + /// gone: the overlay is fed `directRepeaters` only. + /// + /// Additive optional fields do not bump this version. A new watch defaults + /// an absent start-mode list to Passive, absent Ping applicability to false, + /// and absent map-geo state to included. An older phone ignores optional + /// command fields and keeps sending full geography. A bump would therefore + /// strand compatible pairs without preventing a bad decode. + static const int version = 2; + + static const int maxPings = ExternalSurfaceGeoWire.maxPings; + static const int maxRepeaters = ExternalSurfaceGeoWire.maxRepeaters; + + /// Three top-SNR rows plus the RX slot, matching `_buildTopRepeatersOverlay`. + static const int maxHeard = ExternalSurfaceGeoWire.maxHeard; + + /// How long a one-shot cue keeps riding outgoing snapshots. + /// + /// Mirrors `WatchSessionClient.staleAfter`, the boundary past which the watch + /// greys the whole surface and drops a cue rather than asserting a dead + /// failure as current. Keep the two in step: a phone that stops attaching + /// early reintroduces the silent-failure bug this bounds, and one that + /// attaches past the boundary only sends bytes the watch discards. + static const Duration cueReadableFor = Duration(seconds: 90); + + /// Skip a geo-only update unless the fix moved at least this far. Phase + /// changes and new pings always go through; this only suppresses the + /// jitter of a stationary GPS. + static const double minMoveMeters = ExternalSurfaceGeoWire.minMoveMeters; + + /// True when the fix moved far enough to be worth an update on its own. + /// + /// Lives here beside [minMoveMeters], and not with the rest of the geography + /// helpers, so the transport can apply the gate without importing anything + /// that knows what a ping is. + static bool movedEnough({ + required double? lastLat, + required double? lastLon, + required double lat, + required double lon, + double thresholdMeters = minMoveMeters, + }) { + return ExternalSurfaceGeoWire.movedEnough( + lastLat: lastLat, + lastLon: lastLon, + lat: lat, + lon: lon, + thresholdMeters: thresholdMeters, + ); + } + + /// Great-circle distance in metres. + /// + /// Local rather than `Geolocator.distanceBetween` to keep this file free of + /// plugin imports; the maths is identical. + static double distanceMeters( + double lat1, + double lon1, + double lat2, + double lon2, + ) { + return ExternalSurfaceGeoWire.distanceMeters(lat1, lon1, lat2, lon2); + } +} + +/// Start modes the phone may explicitly offer to the wrist. +/// +/// Active remains a phone-only choice. The wrist setting deliberately stays +/// small: Passive is safe everywhere, while Hybrid is advertised only when +/// current zone policy permits transmission. +enum WatchStartMode { + passive, + hybrid; + + static WatchStartMode? fromWire(String value) { + for (final mode in WatchStartMode.values) { + if (mode.name == value) return mode; + } + return null; + } +} + +typedef WatchPosition = ExternalSurfacePosition; +typedef WatchPing = ExternalSurfacePing; +typedef WatchRepeater = ExternalSurfaceRepeater; +typedef WatchHeardNode = ExternalSurfaceHeardNode; +typedef WatchGeo = ExternalSurfaceGeo; + +/// What the wrist may do right now. +/// +/// Drives button enablement only. The phone revalidates every command, so a +/// stale payload can never talk it into an illegal transmit. +class WatchControls { + const WatchControls({ + required this.canStartStop, + required this.canManualPing, + required this.isSessionActive, + this.manualPingApplicable = false, + this.manualCooldownEndsAt, + this.blockedReason, + }); + + final bool canStartStop; + final bool canManualPing; + final bool isSessionActive; + + /// Stable ownership for the corner slot. Unlike [canManualPing], this does + /// not flicker during cooldowns or receive windows; those only disable the + /// ping control that already owns the slot. + final bool manualPingApplicable; + final DateTime? manualCooldownEndsAt; + final String? blockedReason; + + Map toMap() => { + 'canStartStop': canStartStop, + 'canManualPing': canManualPing, + 'isSessionActive': isSessionActive, + 'manualPingApplicable': manualPingApplicable, + 'manualCooldownEndsAtMs': + manualCooldownEndsAt?.millisecondsSinceEpoch.toDouble(), + 'blockedReason': blockedReason, + }; +} + +/// A one-shot event the watch should feel. +/// +/// Carries an [id] so the watch fires exactly once: diffing state would +/// double-fire on redelivery, which WatchConnectivity does routinely. +class WatchHapticCue { + const WatchHapticCue({ + required this.id, + required this.kind, + required this.issuedAt, + this.message, + }); + + final String id; + + /// 'success' | 'failure' | 'notification' + final String kind; + + /// Creation time lets a restarted watch distinguish a current failure from + /// an old cue retained in WatchConnectivity's application context. + final DateTime issuedAt; + + /// Human-readable detail for an event whose outcome arrived after command + /// admission. This additive field is optional, so v2 remains decodable; no + /// wire bump is needed while the matched phone and watch targets ship it. + final String? message; + + /// Whether the watch would still present this cue if it arrived now. + /// + /// The watch applies the same bound on ingest — buzzing inside its own + /// fresh window, showing the message without a haptic up to + /// [WatchWire.cueReadableFor], dropping it after. This is the phone's half + /// of that one rule, not a second policy: it decides how long the cue is + /// worth carrying, and the watch decides how it lands. + bool isPresentableAt(DateTime now) => + now.difference(issuedAt) < WatchWire.cueReadableFor; + + Map toMap() => { + 'id': id, + 'kind': kind, + 'issuedAtMs': issuedAt.millisecondsSinceEpoch.toDouble(), + 'message': message, + }; +} + +/// The complete state the watch renders. +class WatchSnapshot { + const WatchSnapshot({ + required this.core, + required this.geo, + required this.controls, + required this.updatedAt, + this.mapGeoIncluded = true, + this.availableStartModes = const [WatchStartMode.passive], + this.pingColor, + this.cue, + this.phaseDurationMs, + }); + + /// Session core, reused from the Live Activity so both surfaces agree. + final LiveActivitySnapshot core; + final WatchGeo geo; + final WatchControls controls; + + /// False means map-only arrays were deliberately cleared, not that the + /// current area simply has no markers. The wrist uses this to recover from + /// an out-of-order suppressed context when the map is already visible. + final bool mapGeoIncluded; + final List availableStartModes; + final WatchColor? pingColor; + final WatchHapticCue? cue; + final DateTime updatedAt; + + /// Total length of the current phase. + /// + /// With [LiveActivitySnapshot.phaseEndsAt] this is everything the watch needs + /// to draw a depleting progress bar locally — no per-second traffic, and the + /// bar stays correct even if the app opens midway through a phase. + final int? phaseDurationMs; + + Map toMap() => { + 'wireVersion': WatchWire.version, + 'sessionId': core.sessionId, + 'mode': core.mode, + 'phase': core.phase.wireValue, + 'phaseTitle': core.phaseTitle, + 'phaseDetail': core.phaseDetail, + 'phaseEndsAtMs': core.phaseEndsAt?.millisecondsSinceEpoch.toDouble(), + 'phaseDurationMs': phaseDurationMs, + 'isConnected': core.isConnected, + 'zoneCode': core.zoneCode, + 'txCount': core.txCount, + 'rxCount': core.rxCount, + 'discoveryCount': core.discoveryCount, + 'traceCount': core.traceCount, + 'queueSize': core.queueSize, + 'pingColor': pingColor?.toMap(), + 'mapGeoIncluded': mapGeoIncluded, + 'availableStartModes': + availableStartModes.map((mode) => mode.name).toList(), + // Keep the geo object and its keys for older v2 watches, but clear the + // map-only arrays when the wrist has leased suppression. The provider + // also avoids constructing them; this is the last-line wire invariant. + 'geo': geo.toMap(includeMapDetail: mapGeoIncluded), + 'controls': controls.toMap(), + 'cue': cue?.toMap(), + 'updatedAtMs': updatedAt.millisecondsSinceEpoch.toDouble(), + }; + + /// Fields that must bypass the update throttle. + /// + /// Deliberately excludes geo: a moving GPS would otherwise mark every + /// update urgent and defeat the throttle entirely. + String get urgencyKey => buildUrgencyKey( + sessionId: core.sessionId, + mode: core.mode, + phase: core.phase, + phaseTitle: core.phaseTitle, + phaseDetail: core.phaseDetail, + phaseEndsAt: core.phaseEndsAt, + isConnected: core.isConnected, + controls: controls, + cue: cue, + mapGeoIncluded: mapGeoIncluded, + ); + + static String buildUrgencyKey({ + required String sessionId, + required String mode, + required LiveActivityPhase phase, + required String phaseTitle, + required String? phaseDetail, + required DateTime? phaseEndsAt, + required bool isConnected, + required WatchControls controls, + required WatchHapticCue? cue, + bool mapGeoIncluded = true, + }) => + [ + sessionId, + mode, + phase.wireValue, + phaseTitle, + phaseDetail ?? '', + phaseEndsAt?.millisecondsSinceEpoch ?? 0, + isConnected, + controls.canStartStop, + controls.canManualPing, + controls.isSessionActive, + controls.manualPingApplicable, + mapGeoIncluded, + cue?.id ?? '', + ].join('|'); +} + +/// An intent from the wrist. Never state — the phone decides what happens. +enum WatchCommandKind { + startSession, + stopSession, + manualPing, + requestSnapshot; + + static WatchCommandKind? fromWire(String value) { + for (final kind in WatchCommandKind.values) { + if (kind.name == value) return kind; + } + return null; + } +} + +/// Decoded wrist intent. [mode] stays raw until phone-side admission so an +/// unknown value can be refused rather than mistaken for an omitted mode. +class WatchCommand { + const WatchCommand({ + required this.kind, + this.id, + this.issuedAt, + this.mode, + this.mapGeoNeeded, + this.forceRefresh = false, + this.sessionId, + }); + + final WatchCommandKind kind; + final String? id; + final DateTime? issuedAt; + final String? mode; + + /// Which session a [WatchCommandKind.stopSession] means, taken from the + /// snapshot the wearer was looking at when they tapped. + /// + /// Stopping is exempt from the transmit-age window, because a late stop + /// takes the radio off air and refusing it is the worse failure. That + /// exemption quietly assumed sessions were interchangeable. They are not: a + /// Stop queued while the phone was out of range, delivered after that + /// session ended and another began, stopped the wrong one — silently, and + /// the wearer found out by noticing recording had halted. + /// + /// Null from an older watch build, which is admitted exactly as before. + final String? sessionId; + + /// Optional state piggybacked on requestSnapshot. Old phones ignore it and + /// keep the fail-safe full payload; new phones suppress only after a fresh + /// false claim. + final bool? mapGeoNeeded; + + /// Whether the wrist is asking for state, rather than changing what future + /// snapshots contain. Only the former may defeat unchanged-state dedupe; + /// map-geo lease renewals travel as the same command every five minutes and + /// must stay deduplicatable. Absent on the wire means false, so an older + /// watch build behaves as it always did. + final bool forceRefresh; +} + +typedef WatchRequestedStartModeResolution = ({ + WatchStartMode? mode, + String? refusal, +}); + +/// Revalidate an explicit wrist mode against current phone state. +/// +/// A null result means an older watch omitted the field and the provider must +/// retain its established `_resolvedWatchSessionMode` fallback. An unsupported +/// or newly-forbidden request is never downgraded silently. +WatchRequestedStartModeResolution resolveWatchRequestedStartMode({ + required String? requestedMode, + required bool isConnected, + required bool txAllowed, +}) { + if (requestedMode == null) return (mode: null, refusal: null); + + final mode = WatchStartMode.fromWire(requestedMode); + if (mode == null) return (mode: null, refusal: 'Unsupported start mode'); + if (mode == WatchStartMode.hybrid && !isConnected) { + return (mode: null, refusal: 'Not connected'); + } + if (mode == WatchStartMode.hybrid && !txAllowed) { + return (mode: null, refusal: 'Passive Only'); + } + return (mode: mode, refusal: null); +} + +/// Mode used when an older watch sends the pre-mode-picker generic Start. +/// +/// Trace is deliberately absent from the shared external-command model: it +/// requires a phone-selected target. Falling back to Passive keeps an older +/// watch usable after the phone last ran Trace without silently targeting a +/// stale repeater. Other established modes retain the legacy behaviour. +String resolveLegacyWatchStartMode({ + required String currentMode, + required bool isConnected, + required bool txAllowed, +}) { + if (!isConnected || !txAllowed || currentMode == 'targeted') { + return 'passive'; + } + return const {'active', 'passive', 'hybrid'}.contains(currentMode) + ? currentMode + : 'passive'; +} + +/// Whether Settings should offer the watch diagnostics screen. +/// +/// The screen explains why the phone is or is not talking to a watch, so the +/// case it most needs to be reachable in is the one where nothing is working. +/// Requiring [paired] to have been observed true got that exactly backwards: +/// a session whose activation failed reports `paired: false` for a phone that +/// may well have a watch on the wrist, and the entry then stayed invisible for +/// precisely the wearer who needed it. +/// +/// So it appears when a watch can be seen or has ever been seen, and also when +/// WatchConnectivity is supported but the session never came up — which is not +/// a claim that a watch exists, only an admission that we cannot say it does +/// not. A healthy session reporting no watch hides it, which is the common +/// case on an iPhone with no Apple Watch at all. +/// +/// [activated] is briefly false while activation is in flight at launch. That +/// window is not reachable from here: the provider activates during its own +/// initialization, long before Settings can be pushed. +bool resolveShouldShowWatchDiagnostics({ + required bool isSupportedPlatform, + required bool supported, + required bool paired, + required bool activated, + required bool hasEverPaired, +}) { + if (!isSupportedPlatform) return false; + if (paired || hasEverPaired) return true; + return supported && !activated; +} + +/// Which start modes the wrist may offer. +/// +/// Advertisement, not admission. Transient conditions — a ping in flight, a +/// cooldown — belong to [resolveSessionStartAvailability] and would only make +/// the option list flicker; what belongs here is whether a mode could start at +/// all in the current configuration. +/// +/// Offline Mode is part of that configuration. Advertising Hybrid while it is +/// on offered the wearer a button every start refuses with 'Offline Mode' — a +/// permanently dead option on a screen with room for two. +/// +/// So is flood traffic, and that one is the default state rather than an edge. +/// The phone builds Send Ping and the Active/Hybrid button inside +/// `if (!txNotAllowed && floodTrafficVisible)`, so with flood off those +/// controls do not exist — and [floodTrafficEnabled] folds in the regional +/// `flood_disabled` veto, which a zone admin sets and the wearer cannot +/// override. A wrist offering Hybrid there advertises a control the phone +/// withholds, and in the veto case one a zone admin forbade. +List resolveAvailableWatchStartModes({ + required bool isConnected, + required bool txAllowed, + required bool offlineMode, + required bool floodTrafficEnabled, +}) => + [ + WatchStartMode.passive, + if (isConnected && txAllowed && !offlineMode && floodTrafficEnabled) + WatchStartMode.hybrid, + ]; + +typedef SessionStartAvailability = ({ + bool allowed, + ExternalCommandReason? reason, +}); + +/// One start-admission rule shared by the wrist snapshot and command handler. +/// +/// Passive monitoring transmits too: `_startDiscoveryMode` puts a discovery +/// request on air the moment it starts and every 30 s after. So a manual ping +/// already in flight, its receive window, and the TX cooldown gate every mode +/// — a discovery TX interleaved into a manual ping's `TxTracker` window +/// attributes the response to the wrong request, which is exactly why the +/// phone's own Passive button is dead during all three. +/// +/// What stays transmit-only is *policy* rather than timing: offline mode, a +/// passive-only zone, flood traffic being off, and the auto-mode validation +/// reason all describe whether the wearer may originate a TX ping, and the +/// phone's Passive button ignores them all. +/// +/// The setup and transition guards still apply to every mode. Keeping that +/// split here prevents the offered button and the radio admission from +/// drifting back into separate policy copies. +SessionStartAvailability resolveSessionStartAvailability({ + required bool isTransmitMode, + required bool isConnected, + required bool antennaConfigured, + required bool powerConfigured, + required bool isPendingDisable, + required bool isTargetedRunning, + required bool isAutoStarting, + required bool cooldownActive, + required bool isPingSending, + required bool rxWindowActive, + required bool txBlockedByOffline, + required bool txNotAllowed, + required bool floodTrafficEnabled, + required ExternalCommandReason? transmitValidationReason, +}) { + if (!isConnected) { + return (allowed: false, reason: ExternalCommandReason.notConnected); + } + if (isPendingDisable) { + return (allowed: false, reason: ExternalCommandReason.stillStopping); + } + if (isTargetedRunning) { + return (allowed: false, reason: ExternalCommandReason.traceSessionActive); + } + if (isAutoStarting) { + return (allowed: false, reason: ExternalCommandReason.alreadyStarting); + } + if (!antennaConfigured) { + return (allowed: false, reason: ExternalCommandReason.selectAntennaOption); + } + if (!powerConfigured) { + return (allowed: false, reason: ExternalCommandReason.selectPowerLevel); + } + + // Timing guards: every mode reaches the radio, so every mode waits. + if (cooldownActive) { + return (allowed: false, reason: ExternalCommandReason.coolingDown); + } + if (isPingSending) { + return (allowed: false, reason: ExternalCommandReason.pingInProgress); + } + if (rxWindowActive) { + return ( + allowed: false, + reason: ExternalCommandReason.listeningForPingResponse, + ); + } + + if (!isTransmitMode) return (allowed: true, reason: null); + + if (txBlockedByOffline) { + return (allowed: false, reason: ExternalCommandReason.offlineMode); + } + if (txNotAllowed) { + return (allowed: false, reason: ExternalCommandReason.passiveOnly); + } + // True whether the wearer turned flood traffic off or a regional admin did; + // the phone's own gate is the same single effective value, and the wearer's + // next step — look at the phone, where Settings says which it was — is the + // same either way. + if (!floodTrafficEnabled) { + return (allowed: false, reason: ExternalCommandReason.floodTrafficOff); + } + if (transmitValidationReason != null) { + return (allowed: false, reason: transmitValidationReason); + } + return (allowed: true, reason: null); +} + +/// The shared resolver's Starting fallback is correct for a Live Activity, +/// which only exists for a session, but the watch also renders while idle. +/// Only the watch calls this projection, keeping the phone surface unchanged. +LiveActivityPhase resolveWatchSurfacePhase({ + required LiveActivityPhase sharedPhase, + required bool isSessionActive, + required bool isSessionStarting, +}) { + if (sharedPhase == LiveActivityPhase.starting && + !isSessionActive && + !isSessionStarting) { + return LiveActivityPhase.idle; + } + return sharedPhase; +} diff --git a/lib/utils/coverage_summary.dart b/lib/utils/coverage_summary.dart new file mode 100644 index 00000000..c186f9e2 --- /dev/null +++ b/lib/utils/coverage_summary.dart @@ -0,0 +1,906 @@ +import 'dart:math' as math; + +import '../models/repeater.dart'; + +// Client-side coverage aggregation, ported from the web client (dev/index.php) +// so the app's cell GRID SUMMARY and repeater totals stay in parity with the web. +// +// Both views fetch raw coverage points (map_data / repeater_coverage, via +// app_coverage.php) and aggregate here — exactly as generateSummaryContent +// (:13345) and renderRepeaterChart (:14063) do in the browser. +// +// Coverage status codes: 1=BIDIR, 2=TX, 5=RX, 3=DEAD, 0=DROP, 6|7=DISC. + +// --- token + coord helpers (ports of the web equivalents) ------------------- + +/// Matches `hex(?)(snr)[lat,lon]` repeater path tokens. Groups: 1=hex, 2=`?`, +/// 3=snr, 4=`lat,lon`. Mirrors the web regex used in both functions. +final RegExp _tokenRe = RegExp(r'([a-f0-9]+)(\?)?(?:\((.*?)\))?(?:\[(.*?)\])?', + caseSensitive: false); +final RegExp _hexOnlyRe = RegExp(r'[^a-fA-F0-9]'); +final RegExp _snrParenRe = RegExp(r'\(([\d.-]+)\)'); +final RegExp _coordBracketRe = RegExp(r'\[([\d.-]+),([\d.-]+)\]'); + +double _haversineMeters(double lat1, double lon1, double lat2, double lon2) { + const r = + 6371000.0; // same earth radius as Leaflet map.distance + coordsWithin100m + final dLat = (lat2 - lat1) * math.pi / 180; + final dLon = (lon2 - lon1) * math.pi / 180; + final a = math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(lat1 * math.pi / 180) * + math.cos(lat2 * math.pi / 180) * + math.sin(dLon / 2) * + math.sin(dLon / 2); + return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); +} + +/// Port of `coordsWithin100m` (`dev/index.php:6198`). +bool _within100m(double lat1, double lon1, double lat2, double lon2) => + _haversineMeters(lat1, lon1, lat2, lon2) <= 100; + +int _toInt(dynamic v) { + if (v is int) return v; + if (v is num) return v.toInt(); + if (v is String) return int.tryParse(v) ?? -999; + return -999; +} + +double? _toDouble(dynamic v) { + if (v is num) return v.toDouble(); + if (v is String) return double.tryParse(v); + return null; +} + +/// The coverage grid cell containing a coordinate, plus helpers to snap the +/// cell-summary fetch to the cell centre and filter pings to the cell — so the +/// summary is identical no matter where inside the cell the user taps (parity +/// with the web's `lazyShowPingsAt`). Steps come from `kCoverageGridSteps` +/// (mvt_cells.dart), byte-identical to the server grid (dev/coverage_cells.php). +class GridCell { + final int i; + final int j; + final double latStep; + final double lonStep; + const GridCell(this.i, this.j, this.latStep, this.lonStep); + + factory GridCell.containing( + double lat, double lon, double latStep, double lonStep) => + GridCell( + (lat / latStep).floor(), (lon / lonStep).floor(), latStep, lonStep); + + double get centerLat => (i + 0.5) * latStep; + double get centerLon => (j + 0.5) * lonStep; + + bool contains(double lat, double lon) => + (lat / latStep).floor() == i && (lon / lonStep).floor() == j; + + /// Keep only points whose own cell is this one (parses lat/lon from the raw map). + List> filter(List> points) { + return points.where((p) { + final la = _toDouble(p['lat']); + final lo = _toDouble(p['lon']); + return la != null && lo != null && contains(la, lo); + }).toList(); + } + + // The coverage tile smears each ping over a (2·blob+1)² block of cells + // (server `$blob`: Detailed 100 m = 1 → 3×3, Simplified 300 m = 0 → 1×1). So a + // green cell can be coloured by a ping up to [blob] cells away. These two + // helpers mirror the web's `lazyShowPingsAt` (dev/index.php) so the app fetches + // and keeps exactly the pings that coloured the tapped cell — otherwise a + // blob-painted neighbour cell falsely reads "no coverage data here". blob=0 + // collapses both back to the own-cell behaviour, unchanged. + + /// Metres from the cell centre to the far corner of the ±[blob] cell block — + /// the fetch radius that reaches every ping whose blob can colour this cell. + /// Floored at [minMeters] (the active gridSize) so blob=0 keeps the old radius. + double blobFetchRadiusMeters(int blob, double minMeters) { + final latM = (blob + 0.5) * latStep * 111000; + final lonM = + (blob + 0.5) * lonStep * 111000 * math.cos(centerLat * math.pi / 180); + final corner = math.sqrt(latM * latM + lonM * lonM).ceilToDouble() + 5; + return math.max(corner, minMeters); + } + + /// Keep the points whose blob covers this cell: own cell ±[blob] in each axis + /// (blob=0 reduces to own-cell-only, i.e. [filter]). + List> filterWithinBlob( + List> points, int blob) { + return points.where((p) { + final la = _toDouble(p['lat']); + final lo = _toDouble(p['lon']); + if (la == null || lo == null) return false; + return ((la / latStep).floor() - i).abs() <= blob && + ((lo / lonStep).floor() - j).abs() <= blob; + }).toList(); + } + + /// The closed outer ring (`[lon, lat]` pairs, GeoJSON order) of the + /// (2·[blob]+1)² block of cells centred on this cell — the 3×3 highlight + /// block in Detailed (blob = 1), the single cell in Simplified (blob = 0). + /// The cell itself is always the middle, so a tap highlights a block centred + /// on the tapped tile (parity with the web's clicked-tile-centred highlight). + List> blockRing(int blob) { + final minLat = (i - blob) * latStep; + final maxLat = (i + blob + 1) * latStep; + final minLon = (j - blob) * lonStep; + final maxLon = (j + blob + 1) * lonStep; + return [ + [minLon, minLat], + [maxLon, minLat], + [maxLon, maxLat], + [minLon, maxLat], + [minLon, minLat], + ]; + } + + /// One closed ring (`[lon, lat]` pairs) per individual cell in the + /// (2·[blob]+1)² block centred on this cell — nine cells in Detailed + /// (blob = 1), one in Simplified (blob = 0). Used to fill the tapped footprint + /// as a grid of bordered cells, matching the web's nine `L.rectangle`s. + List>> blockCellPolygons(int blob) { + final rings = >>[]; + for (var di = -blob; di <= blob; di++) { + for (var dj = -blob; dj <= blob; dj++) { + final minLat = (i + di) * latStep; + final maxLat = (i + di + 1) * latStep; + final minLon = (j + dj) * lonStep; + final maxLon = (j + dj + 1) * lonStep; + rings.add([ + [minLon, minLat], + [maxLon, minLat], + [maxLon, maxLat], + [minLon, maxLat], + [minLon, minLat], + ]); + } + } + return rings; + } +} + +/// The single dominant coverage `st` category (1..6) across [points], or null +/// when empty. Port of the web's `highlightSpotCoverage` colour pick: each ping +/// maps to a status bucket, and the highest-priority one present wins +/// (green < cyan < orange < purple < grey < red, i.e. the lowest st). Because +/// the caller passes only the pings whose blob covers the tapped cell, this is +/// the "intentional smear" — a green-looking neighbour repaints to the local +/// dominant when its green came from a ping outside the blob. +int? dominantCoverageStatus(List> points) { + int? best; + for (final p in points) { + final st = _pointStatusCategory(p); + if (best == null || st < best) best = st; + } + return best; +} + +/// A single ping's coverage `st` category (1=green, 2=cyan, 3=orange, +/// 4=purple, 5=grey, 6=red). Mirrors `highlightSpotCoverage` / +/// `coverageStatusColor`: status 1 (BIDIR) is green either way, so the +/// repeats check only documents the web's branch. +int _pointStatusCategory(Map p) { + final status = _toInt(p['status']); + final hr = p['heard_repeats']; + final dh = p['direct_heard']; + final hasRepeats = (hr is String && hr.isNotEmpty && hr != 'None') || + (dh is String && dh.isNotEmpty && dh != 'None'); + if (status == 1 && hasRepeats) return 1; // green + if (status == 2) return 3; // orange (TX) + if (status == 5) return 4; // purple (RX) + if (status == 6 || status == 7) return 2; // cyan (DISC/TRACE) + if (status == 1) return 1; // green (BIDIR) + if (status == 3) return 5; // grey (dead) + return 6; // red (drop / unknown) +} + +// --- repeater lookup (ports repObj / repeaterByHex / repeaterByFullHex) ------ + +class _RepInfo { + final String id; // short hex id, lowercased + final String hexId; // full hex, cleaned + lowercased + final double lat; + final double lon; + final int status; // enabled: 1 active, 2 ambiguous/excluded + final bool hidden; + const _RepInfo( + this.id, this.hexId, this.lat, this.lon, this.status, this.hidden); +} + +/// Indexes the loaded repeaters the same three ways the web does: by id +/// (`repeaterLocs`), by full hex (`repeaterByFullHex`), and by short-hex bucket +/// (`repeaterByHex`), plus `narrowCandidates`/`prefixLen` for token resolution. +class RepeaterLookup { + final int prefixLen; + final Map _byId = {}; + final Map _byFullHex = {}; + final Map> _byShortHex = {}; + + RepeaterLookup._(this.prefixLen); + + /// [hopBytes] is the region path width (web `hopBytes`); `prefixLen = hopBytes*2`. + factory RepeaterLookup.fromRepeaters(Iterable repeaters, + {required int hopBytes}) { + final prefixLen = math.max(2, hopBytes * 2); + final lk = RepeaterLookup._(prefixLen); + for (final r in repeaters) { + // web adds enabled IN (1,2) to the lookup + if (r.enabled != 1 && r.enabled != 2) continue; + if (r.lat.isNaN || r.lon.isNaN) continue; + // Web parity (index.php:10231 `if (rep.lat && rep.lon && rep.id)`): a 0 + // lat/lon is the "location not published" sentinel — exclude it, or MAX + // DIST / max range would be computed to (0,0) (~8900 km from Ottawa). + if (r.lat == 0 || r.lon == 0 || r.id.isEmpty) continue; + final cleanHex = r.hexId.replaceAll(_hexOnlyRe, '').toLowerCase(); + final id = r.id.toLowerCase(); + // Web parity (index.php:10239): hidden only when the 🚫 marker is at the + // start or end of the name — NOT anywhere in the middle. + final hidden = r.name.startsWith('🚫') || r.name.endsWith('🚫'); + final info = _RepInfo(id, cleanHex, r.lat, r.lon, r.enabled, hidden); + lk._byId[id] = info; + final shortHex = cleanHex.length >= prefixLen + ? cleanHex.substring(0, prefixLen) + : cleanHex; + if (shortHex.length >= 2) { + (lk._byShortHex[shortHex] ??= <_RepInfo>[]).add(info); + final firstByte = cleanHex.length >= 2 ? cleanHex.substring(0, 2) : ''; + if (firstByte.length == 2 && firstByte != shortHex) { + (lk._byShortHex[firstByte] ??= <_RepInfo>[]).add(info); + } + } + if (cleanHex.isNotEmpty) lk._byFullHex[cleanHex] = info; + } + return lk; + } + + /// Port of `narrowCandidates` (`dev/index.php:9709`): greedy narrowing on a + /// longer id, backward-compat widening on a shorter id. + List<_RepInfo> _narrowCandidates(List<_RepInfo> candidates, String id) { + final idLower = id.toLowerCase(); + if (candidates.length > 1 && id.length > prefixLen) { + final narrowed = candidates + .where((rep) => + rep.hexId.length >= id.length && + rep.hexId.substring(0, id.length) == idLower) + .toList(); + if (narrowed.isNotEmpty) return narrowed; + } + if (candidates.isEmpty && id.isNotEmpty && id.length < prefixLen) { + final results = <_RepInfo>[]; + _byShortHex.forEach((key, reps) { + if (key.length >= idLower.length && + key.substring(0, idLower.length) == idLower) { + for (final rep in reps) { + if (!results.contains(rep)) results.add(rep); + } + } + }); + if (results.isNotEmpty) return results; + } + return candidates; + } + + /// Resolve a token hex to candidate repeaters, mirroring the web order: + /// full-hex, else narrowed short-hex bucket, else `repeaterLocs[id]`. + List<_RepInfo> _candidatesFor(String rid) { + final full = _byFullHex[rid]; + if (full != null) return <_RepInfo>[full]; + final short = rid.length >= prefixLen ? rid.substring(0, prefixLen) : rid; + final cands = + _narrowCandidates(_byShortHex[short] ?? const <_RepInfo>[], rid); + if (cands.isEmpty) { + final loc = _byId[rid]; + if (loc != null) return <_RepInfo>[loc]; + } + return cands; + } + + /// Resolve a token hex (any width — old narrow OR new 4-byte-capped) to its single repeater, + /// or null. Mirrors the web `repObjForToken`: full-hex, else a UNIQUE short-hex/narrowed match, + /// else the legacy id key. Tokens normalize WIDER than `repeater.id`, so a bare `_byId[token]` + /// lookup misses — the cell MAX-DIST must resolve by full-hex. + _RepInfo? _repForToken(String rid) { + final full = _byFullHex[rid]; + if (full != null) return full; + final short = rid.length >= prefixLen ? rid.substring(0, prefixLen) : rid; + final cands = + _narrowCandidates(_byShortHex[short] ?? const <_RepInfo>[], rid); + if (cands.length == 1) return cands.first; + return _byId[rid]; + } +} + +// --- cell GRID SUMMARY ------------------------------------------------------- + +/// Aggregated stats for a clicked map cell. Port of `generateSummaryContent` +/// (`dev/index.php:13345-13521`). +class GridSummary { + final int total; + final int bidir; + final int tx; + final int rx; + final int disc; + final int dead; + final int drop; + final double? avgSnr; // null = N/A + final int? avgNoise; // rounded dBm; null = N/A + final double? maxDistMeters; // null = N/A + + const GridSummary({ + required this.total, + required this.bidir, + required this.tx, + required this.rx, + required this.disc, + required this.dead, + required this.drop, + required this.avgSnr, + required this.avgNoise, + required this.maxDistMeters, + }); + + /// Signal bucket driving the AVG SNR icon/colour: `good` (>5), `medium` (>=-1), + /// `bad` (<-1), or null when AVG SNR is N/A (web thresholds at `:13456`). + String? get snrBucket { + if (avgSnr == null) return null; + final v = double.parse(avgSnr!.toStringAsFixed(1)); + if (v > 5) return 'good'; + if (v >= -1) return 'medium'; + return 'bad'; + } + + factory GridSummary.fromPoints( + List> points, RepeaterLookup lookup) { + int bidir = 0, tx = 0, rx = 0, dead = 0, drop = 0, disc = 0; + double totalSnr = 0; + int countSnr = 0; + double totalNoise = 0; + int countNoise = 0; + double maxDist = 0; + + for (final p in points) { + final s = _toInt(p['status']); + if (s == 1) { + bidir++; + } else if (s == 2) { + tx++; + } else if (s == 5) { + rx++; + } else if (s == 3) { + dead++; + } else if (s == 0) { + drop++; + } else if (s == 6 || s == 7) { + disc++; + } + + // SNR: local_snr, else max SNR parsed from heard_repeats "(x)" tokens. + double? pingSnr = _toDouble(p['local_snr']); + if (pingSnr == null) { + final hr = p['heard_repeats']; + if (hr is String && hr.isNotEmpty && hr != 'None') { + double maxS = -999; + bool found = false; + for (final part in hr.split(',')) { + final m = _snrParenRe.firstMatch(part); + if (m != null) { + final v = double.tryParse(m.group(1)!); + if (v != null) { + if (v > maxS) maxS = v; + found = true; + } + } + } + if (found) pingSnr = maxS; + } + } + if (pingSnr != null && !pingSnr.isNaN) { + totalSnr += pingSnr; + countSnr++; + } + + // Noise. + final nf = _toDouble(p['noisefloor']); + if (nf != null && !nf.isNaN) { + totalNoise += nf; + countNoise++; + } + + // Max range: token coords matched to a repeater (by id) within 100 m. + final ids = []; + final pLat = _toDouble(p['lat']); + final pLon = _toDouble(p['lon']); + + void collectFrom(String text) { + for (final m in _tokenRe.allMatches(text)) { + if (m.group(2) == '?' || m.group(4) == null) continue; + final c = m.group(4)!.split(','); + if (c.length < 2) continue; + final tLat = double.tryParse(c[0]); + final tLon = double.tryParse(c[1]); + final rID = m.group(1)!.toLowerCase(); + final loc = lookup._repForToken(rID); + if (loc != null && + tLat != null && + tLon != null && + _within100m(loc.lat, loc.lon, tLat, tLon)) { + ids.add(m.group(1)!); + } + } + } + + final hr = p['heard_repeats']; + if (hr is String && hr.isNotEmpty && hr != 'None') collectFrom(hr); + + final via = p['via']; + if (via is String && + via.isNotEmpty && + via != 'Direct' && + !via.contains('Wardriving')) { + final cleanVia = via + .replaceAll(RegExp(r'\bDirect\b', caseSensitive: false), '') + .replaceAll(RegExp(r'\bNone\b', caseSensitive: false), '') + .replaceAll(RegExp(r'\bN/A\b', caseSensitive: false), ''); + collectFrom(cleanVia); + } + + // DISC full-id check via public_key. + if (s == 6 && p['public_key'] is String) { + final pkClean = (p['public_key'] as String) + .replaceAll(_hexOnlyRe, '') + .toLowerCase(); + final targetRep = lookup._byFullHex[pkClean]; + if (targetRep != null) { + final hrStr = hr is String ? hr : ''; + final cm = _coordBracketRe.firstMatch(hrStr); + if (cm != null) { + final tLat = double.tryParse(cm.group(1)!); + final tLon = double.tryParse(cm.group(2)!); + if (tLat != null && + tLon != null && + _within100m(targetRep.lat, targetRep.lon, tLat, tLon)) { + ids.add(targetRep.id); + } + } + } + } + + for (final rID in ids) { + final loc = lookup._repForToken(rID.toLowerCase()); + if (loc == null) continue; + if (loc.status == 2 || loc.hidden) continue; + if (pLat != null && pLon != null) { + final d = _haversineMeters(pLat, pLon, loc.lat, loc.lon); + if (d > maxDist) maxDist = d; + } + } + } + + return GridSummary( + total: points.length, + bidir: bidir, + tx: tx, + rx: rx, + disc: disc, + dead: dead, + drop: drop, + avgSnr: countSnr > 0 ? totalSnr / countSnr : null, + avgNoise: countNoise > 0 ? (totalNoise / countNoise).round() : null, + maxDistMeters: maxDist > 0 ? maxDist : null, + ); + } +} + +// --- per-repeater totals ----------------------------------------------------- + +/// Per-repeater BIDIR/TX/RX/DISC/DEAD counts + max range. Port of the attribution +/// in `renderRepeaterChart` (`dev/index.php:14063-14322`). +class RepeaterStats { + final int bidir; + final int tx; + final int rx; + final int disc; + final int dead; + final double? maxRangeMeters; + + const RepeaterStats({ + required this.bidir, + required this.tx, + required this.rx, + required this.disc, + required this.dead, + required this.maxRangeMeters, + }); + + int get totalMatched => bidir + tx + rx + disc + dead; + + factory RepeaterStats.fromCoverage(List> points, + Repeater target, RepeaterLookup lookup, + {bool disableDupLogic = false}) => + fromCoverageWithPoints(points, target, lookup, + disableDupLogic: disableDupLogic) + .stats; + + /// Like [fromCoverage] but ALSO returns the raw points that matched this + /// repeater (parity with the web client's `buildChartFromPoints` + /// `_matchedPoints`), so callers can draw the repeater's coverage cells + + /// connection lines. [fromCoverage] delegates here, so there is ONE source of + /// matching truth. + static ({ + RepeaterStats stats, + List> matched + }) fromCoverageWithPoints( + List> points, Repeater target, RepeaterLookup lookup, + {bool disableDupLogic = false}) { + final matchedPoints = >[]; + final repId = target.id.toLowerCase(); + final repLat = target.lat; + final repLon = target.lon; + int bidir = 0, tx = 0, rx = 0, disc = 0, dead = 0; + double maxRange = 0; + + bool tokenMatchesTarget(RegExpMatch m) { + if (!disableDupLogic && m.group(2) == '?') return false; + final rID = m.group(1)!.toLowerCase(); + final candidates = lookup._candidatesFor(rID); + if (!candidates.any((c) => c.id == repId)) return false; + final coords = m.group(4); + if (coords != null) { + final c = coords.split(','); + if (c.length < 2) return false; + final cLat = double.tryParse(c[0]); + final cLon = double.tryParse(c[1]); + return cLat != null && + cLon != null && + _within100m(repLat, repLon, cLat, cLon); + } + return disableDupLogic; // no coords: only valid when dup-logic disabled + } + + for (final p in points) { + final pLat = _toDouble(p['lat']); + final pLon = _toDouble(p['lon']); + if (pLat == null || pLon == null) continue; + + final status = _toInt(p['status']); + final String type; + if (status == 1) { + type = 'BIDIR'; + } else if (status == 2) { + type = 'TX'; + } else if (status == 5) { + type = 'RX'; + } else if (status == 6 || status == 7) { + type = 'DISC'; + } else if (status == 3) { + type = 'DEAD'; + } else { + continue; // DROP / unknown -> skip (matches web) + } + + bool matched = false; + + final hr = p['heard_repeats']; + if (hr is String && hr.isNotEmpty && hr != 'None') { + for (final m in _tokenRe.allMatches(hr)) { + if (tokenMatchesTarget(m)) matched = true; + } + } + + final via = p['via']; + if (via is String && + via.isNotEmpty && + via != 'Direct' && + !via.contains('Wardriving')) { + final cleanVia = via + .replaceAll(RegExp(r'\bDirect\b', caseSensitive: false), '') + .replaceAll(RegExp(r'\bNone\b', caseSensitive: false), '') + .replaceAll(RegExp(r'\bN/A\b', caseSensitive: false), ''); + for (final m in _tokenRe.allMatches(cleanVia)) { + if (tokenMatchesTarget(m)) matched = true; + } + } + + if (status == 6 && p['public_key'] is String) { + final pk = (p['public_key'] as String) + .replaceAll(_hexOnlyRe, '') + .toLowerCase(); + final tr = lookup._byFullHex[pk]; + if (tr != null && tr.id == repId) { + final hrStr = hr is String ? hr : ''; + final cm = _coordBracketRe.firstMatch(hrStr); + if (cm != null) { + final cLat = double.tryParse(cm.group(1)!); + final cLon = double.tryParse(cm.group(2)!); + if (cLat != null && + cLon != null && + _within100m(repLat, repLon, cLat, cLon)) { + matched = true; + } + } + } + } + + if (matched) { + matchedPoints.add(p); + switch (type) { + case 'BIDIR': + bidir++; + break; + case 'TX': + tx++; + break; + case 'RX': + rx++; + break; + case 'DISC': + disc++; + break; + case 'DEAD': + dead++; + break; + } + final d = _haversineMeters(pLat, pLon, repLat, repLon); + if (d > maxRange) maxRange = d; + } + } + + final stats = RepeaterStats( + bidir: bidir, + tx: tx, + rx: rx, + disc: disc, + dead: dead, + maxRangeMeters: maxRange > 0 ? maxRange : null, + ); + return (stats: stats, matched: matchedPoints); + } +} + +// --- repeater coverage cells (Feature B) ------------------------------------- + +/// One coverage grid cell that a selected repeater heard, ready to render as a +/// status-coloured footprint cell plus a connection line to the repeater. Port +/// of the cell-dedup loop in the web client's `drawRepeaterCoverageFromCache` +/// (`dev/index.php:12208-12228`). +class RepeaterCoverageCell { + final int li; // latitude grid index + final int lj; // longitude grid index + final int st; // coverage status category 1..6 (lower = higher priority) + final double centerLat; + final double centerLon; + final double distanceMeters; // repeater -> cell centre (for the volume cap) + + const RepeaterCoverageCell({ + required this.li, + required this.lj, + required this.st, + required this.centerLat, + required this.centerLon, + required this.distanceMeters, + }); +} + +/// Dedups [matchedPoints] (from [RepeaterStats.fromCoverageWithPoints]) into the +/// grid cells the repeater heard, dilating each ping over a (2·[blob]+1)² block +/// (3×3 Detailed, 1×1 Simplified) and keeping the highest-priority status per +/// cell. Mirrors `drawRepeaterCoverageFromCache`: our `st` runs 1=green..6=red +/// (lower wins, INVERTED vs the web's `prio` map), so the keep test is +/// `newSt < existingSt`. Drops st==6 (red/DROP) cells — the app's vector overlay +/// is always the tiles/"advanced" equivalent, where the web hides drops +/// (`useAdv && st==='red'`). [latStep]/[lonStep] come from `kCoverageGridSteps`. +List repeaterCoverageCells( + List> matchedPoints, Repeater target, + {required double latStep, required double lonStep, required int blob}) { + final cells = {}; + for (final p in matchedPoints) { + final la = _toDouble(p['lat']); + final lo = _toDouble(p['lon']); + if (la == null || lo == null) continue; + final st = _pointStatusCategory(p); + if (st == 6) continue; // hide red/DROP cells (tiles/advanced parity) + final li0 = (la / latStep).floor(); + final lj0 = (lo / lonStep).floor(); + for (var dx = -blob; dx <= blob; dx++) { + for (var dy = -blob; dy <= blob; dy++) { + final li = li0 + dx; + final lj = lj0 + dy; + final key = '${li}_$lj'; + final existing = cells[key]; + if (existing != null && st >= existing.st) continue; // lower st wins + final centerLat = (li + 0.5) * latStep; + final centerLon = (lj + 0.5) * lonStep; + cells[key] = RepeaterCoverageCell( + li: li, + lj: lj, + st: st, + centerLat: centerLat, + centerLon: centerLon, + distanceMeters: + _haversineMeters(target.lat, target.lon, centerLat, centerLon), + ); + } + } + } + return cells.values.toList(); +} + +// --- cell fan-out endpoints (Feature A) -------------------------------------- + +/// One repeater endpoint that heard a ping in a tapped cell, ready to render as +/// a fan-out connection line from the cell centre. +class HeardEndpoint { + final double lat; // repeater location (the dedup key, 6dp) + final double lon; + final double? snr; // best SNR seen (parity / optional labelling) + final String repeaterId; // resolved repeater id (drives the fade set) + final double distanceMeters; // cell centre -> repeater (for the volume cap) + + const HeardEndpoint({ + required this.lat, + required this.lon, + required this.snr, + required this.repeaterId, + required this.distanceMeters, + }); +} + +final RegExp _hexRunRe = RegExp(r'[a-f0-9]+', caseSensitive: false); + +/// Resolves the UNIQUE repeater endpoints that heard any of [blobPoints] (the +/// pings whose blob covers a tapped cell), for the tile fan-out lines. Exact +/// port of the web client's `updateActiveLinesInternal` (`dev/index.php:13830`) +/// with the `drawLine`-intercept endpoint collection from `updateAllActiveLines` +/// (`:13740`): dedup by endpoint lat/lon (6dp, first-wins), skip duplicate-styled +/// and hidden candidates, skip VIA tokens already heard, honour the DISC +/// public_key pre-branch and the DISC/TRACE coord requirement. The +/// ambiguous-`?`-no-candidate fallback is omitted because it only ever yields +/// duplicate-marked endpoints (which the intercept skips). +List heardEndpointsForCell( + List> blobPoints, RepeaterLookup lookup, + {required double startLat, required double startLon}) { + final seen = {}; + + void addEndpoint(_RepInfo rep, double? snr) { + if (rep.hidden) return; + // Self-line guard (web drawLine skips start==end within 1e-6). + if ((startLat - rep.lat).abs() < 1e-6 && + (startLon - rep.lon).abs() < 1e-6) { + return; + } + final key = '${rep.lat.toStringAsFixed(6)},${rep.lon.toStringAsFixed(6)}'; + if (seen.containsKey(key)) return; // first-wins (mirror web seenEndpoints) + seen[key] = HeardEndpoint( + lat: rep.lat, + lon: rep.lon, + snr: snr, + repeaterId: rep.id, + distanceMeters: _haversineMeters(startLat, startLon, rep.lat, rep.lon), + ); + } + + for (final p in blobPoints) { + final status = _toInt(p['status']); + if (status == 0) continue; + + // 0. DISC public_key pre-branch (web :13841): resolve the responding + // repeater by full hex and add it (coord-validated), then skip this ping. + if (status == 6 && p['public_key'] is String) { + final pkClean = + (p['public_key'] as String).replaceAll(_hexOnlyRe, '').toLowerCase(); + final targetRep = lookup._byFullHex[pkClean]; + if (targetRep != null) { + final hr = + p['heard_repeats'] is String ? p['heard_repeats'] as String : ''; + final cm = _coordBracketRe.firstMatch(hr); + if (cm != null) { + final tLat = double.tryParse(cm.group(1)!); + final tLon = double.tryParse(cm.group(2)!); + if (tLat != null && + tLon != null && + _within100m(targetRep.lat, targetRep.lon, tLat, tLon)) { + addEndpoint(targetRep, + _toDouble(p['remote_snr']) ?? _toDouble(p['local_snr'])); + } + } + continue; // web returns the whole ping here + } + } + + // Combined heard list + the heardIDs set used to skip already-heard VIAs. + final rawRepeats = []; + final hrRaw = p['heard_repeats']; + if (hrRaw is String && hrRaw.isNotEmpty && hrRaw != 'None') { + rawRepeats.add(hrRaw); + } + final dhRaw = p['direct_heard']; + if (dhRaw is String && dhRaw.isNotEmpty && dhRaw != 'None') { + rawRepeats.add(dhRaw); + } + final combinedRepeats = rawRepeats.join(' '); + final heardIDs = {}; + for (final m in _hexRunRe.allMatches(combinedRepeats)) { + heardIDs.add(m.group(0)!.toLowerCase()); + } + + // 1. VIA lines (web :13883): path tokens, skipping any also in heardIDs. + final via = p['via']; + if (via is String && via.isNotEmpty && via != 'Direct' && via != 'N/A') { + final cleanVia = via + .replaceAll(RegExp(r'\bDirect\b', caseSensitive: false), '') + .replaceAll(RegExp(r'\bNone\b', caseSensitive: false), '') + .replaceAll(RegExp(r'\bN/A\b', caseSensitive: false), ''); + for (final m in _tokenRe.allMatches(cleanVia)) { + final normId = m.group(1)!.toLowerCase(); + if (normId == 'd' || normId == 'direct') continue; + if (heardIDs.contains(normId)) continue; + var candidates = lookup._candidatesFor(normId); + final coordStr = m.group(4); + if (coordStr != null) { + final hasDup = candidates.any((rep) => rep.status == 2); + if (!hasDup) { + final c = coordStr.split(','); + final pLat = c.length >= 2 ? double.tryParse(c[0]) : null; + final pLon = c.length >= 2 ? double.tryParse(c[1]) : null; + candidates = (pLat == null || pLon == null) + ? const <_RepInfo>[] + : candidates + .where((rep) => _within100m(rep.lat, rep.lon, pLat, pLon)) + .toList(); + } + } else { + candidates = const <_RepInfo>[]; + } + for (final rep in candidates) { + if (rep.status == 2 && status != 6) continue; // dup -> skip endpoint + addEndpoint(rep, null); + } + } + } + + // 2. HEARD lines (web :13934): signal tokens, SNR-carried. + for (final m in _tokenRe.allMatches(combinedRepeats)) { + final rID = m.group(1)!.toLowerCase(); + var dbm = m.group(3); + final coordStr = m.group(4); + var candidates = lookup._candidatesFor(rID); + if (coordStr != null) { + final hasDup = candidates.any((rep) => rep.status == 2); + final c = coordStr.split(','); + final pLat = c.length >= 2 ? double.tryParse(c[0]) : null; + final pLon = c.length >= 2 ? double.tryParse(c[1]) : null; + if (!hasDup) { + candidates = (pLat == null || pLon == null) + ? const <_RepInfo>[] + : candidates + .where((rep) => _within100m(rep.lat, rep.lon, pLat, pLon)) + .toList(); + } + if (status == 6 || status == 7) { + candidates = (pLat == null || pLon == null) + ? const <_RepInfo>[] + : candidates + .where((rep) => _within100m(rep.lat, rep.lon, pLat, pLon)) + .toList(); + } + } else { + candidates = + const <_RepInfo>[]; // no coords -> drop (default dup logic) + } + if ((dbm == null || dbm.isEmpty) && (status == 6 || status == 7)) { + dbm = (_toDouble(p['remote_snr']) ?? _toDouble(p['local_snr'])) + ?.toString(); + } + final snr = dbm != null ? double.tryParse(dbm) : null; + final fullHexMatch = lookup._byFullHex[rID] != null; + for (final rep in candidates) { + final isDup = + rep.status == 2 && !fullHexMatch && status != 6 && status != 7; + if (isDup) continue; // dup -> skip endpoint + addEndpoint(rep, snr); + } + } + } + + return seen.values.toList(); +} diff --git a/lib/utils/coverage_tile_palette.dart b/lib/utils/coverage_tile_palette.dart new file mode 100644 index 00000000..16555fcc --- /dev/null +++ b/lib/utils/coverage_tile_palette.dart @@ -0,0 +1,95 @@ +/// Colour tables + MapLibre style expressions for the VECTOR coverage tiles +/// (`vector_tile.php`). The server emits only an integer status category per +/// cell (`st`); colour is applied client-side via a `match` expression, so the +/// rendered fills are exactly the colours the raster tiles used to bake. +/// +/// KEEP IN SYNC with `getCvdPaletteHex()` in MeshMapper_Server +/// `dev/cvd_palettes.php` — these are the server raster fill/border values, +/// NOT the lighter legend swatches in [PingColors] (those are tuned for how +/// the raster looks after layer opacity and stay legend-only). +/// +/// `st` enum (ascending priority, lower wins; see VECTOR_TILES.md): +/// 1=green (BIDIR), 2=cyan (DISC/TRACE), 3=orange (TX), 4=purple (RX), +/// 5=grey (dead), 6=red (fail). +class CoverageTilePalette { + CoverageTilePalette._(); + + /// cvd mode -> [st 1..6] -> [fill, border] hex. + static const Map>> _palettes = { + 'none': [ + ['#1e7e34', '#14522d'], // 1 green + ['#17a2b8', '#117a8b'], // 2 cyan + ['#fd7e14', '#d96b0c'], // 3 orange + ['#6f42c1', '#59359a'], // 4 purple + ['#6c757d', '#545b62'], // 5 grey + ['#bd2130', '#8b101b'], // 6 red + ], + 'protanopia': [ + ['#0072B2', '#00507D'], + ['#56B4E9', '#3C7EA3'], + ['#E69F00', '#A16F00'], + ['#CC79A7', '#8F5575'], + ['#9E9E9E', '#6F6F6F'], + ['#D55E00', '#954200'], + ], + // Server maps deuteranopia to the protanopia palette. + 'deuteranopia': [ + ['#0072B2', '#00507D'], + ['#56B4E9', '#3C7EA3'], + ['#E69F00', '#A16F00'], + ['#CC79A7', '#8F5575'], + ['#9E9E9E', '#6F6F6F'], + ['#D55E00', '#954200'], + ], + 'tritanopia': [ + ['#009E73', '#006F51'], + ['#E69F00', '#A16F00'], + ['#CC79A7', '#8F5575'], + ['#CC79A7', '#8F5575'], + ['#9E9E9E', '#6F6F6F'], + ['#D55E00', '#954200'], + ], + 'achromatopsia': [ + ['#E0E0E0', '#9D9D9D'], + ['#BDBDBD', '#848484'], + ['#9E9E9E', '#6F6F6F'], + ['#757575', '#525252'], + ['#616161', '#444444'], + ['#424242', '#2E2E2E'], + ], + }; + + static List> _paletteFor(String cvdMode) => + _palettes[cvdMode] ?? _palettes['none']!; + + /// `[fill, border]` hex for coverage status [st] (1..6, clamped) under + /// [cvdMode] — the literal colours used to paint a tapped cell's footprint + /// in one uniform colour (the web's `highlightSpotCoverage` block fill). + static List colorsForStatus(String cvdMode, int st) => + _paletteFor(cvdMode)[st.clamp(1, 6) - 1]; + + /// Builds `['match', ['get','st'], 1, c1, ..., 5, c5, c6]` — st 6 doubles + /// as the match default so unknown future codes render as red, the same + /// fallthrough the server-side mapping uses. + static List _matchExpression(String cvdMode, int hexIndex) { + final pal = _paletteFor(cvdMode); + final expr = [ + 'match', + ['get', 'st'], + ]; + for (var st = 1; st <= 5; st++) { + expr.add(st); + expr.add(pal[st - 1][hexIndex]); + } + expr.add(pal[5][hexIndex]); // default (st 6 / unknown) + return expr; + } + + /// MapLibre `fill-color` expression for the user's colour-vision mode. + static List fillColorExpression(String cvdMode) => + _matchExpression(cvdMode, 0); + + /// MapLibre `fill-outline-color` expression (the raster's 1px cell border). + static List borderColorExpression(String cvdMode) => + _matchExpression(cvdMode, 1); +} diff --git a/lib/utils/debug_logger.dart b/lib/utils/debug_logger.dart index 2b4d3990..f5d5cd5e 100644 --- a/lib/utils/debug_logger.dart +++ b/lib/utils/debug_logger.dart @@ -9,10 +9,10 @@ import 'package:flutter/foundation.dart'; import 'package:web/web.dart' as web; /// Debug logging utility that mirrors MeshMapper_WebClient debug system. -/// +/// /// Logs are only output when DEBUG_ENABLED is true (set via `?debug=1` URL param). /// All log messages should use tagged format: `[TAG] message` -/// +/// /// Common tags: [BLE], [GPS], [PING], [API], [RX], [UI], [CONN] class DebugLogger { static bool _debugEnabled = false; @@ -30,7 +30,7 @@ class DebugLogger { final uri = Uri.base; final debugParam = uri.queryParameters['debug']; _debugEnabled = debugParam == '1' || debugParam == 'true'; - + if (_debugEnabled) { _consoleLog('[DEBUG] Debug logging ENABLED via URL param'); } @@ -56,9 +56,14 @@ class DebugLogger { /// Use tagged format: debugLog('[BLE] Connected to device'); static void log(String message, [Object? arg1, Object? arg2, Object? arg3]) { if (!_debugEnabled) return; - - final args = [message, if (arg1 != null) arg1, if (arg2 != null) arg2, if (arg3 != null) arg3]; - + + final args = [ + message, + if (arg1 != null) arg1, + if (arg2 != null) arg2, + if (arg3 != null) arg3 + ]; + if (kIsWeb) { _consoleLog(args.join(' ')); } else { @@ -70,9 +75,15 @@ class DebugLogger { /// Use tagged format: debugWarn('[GPS] Position stale, re-acquiring'); static void warn(String message, [Object? arg1, Object? arg2, Object? arg3]) { if (!_debugEnabled) return; - - final args = ['⚠️', message, if (arg1 != null) arg1, if (arg2 != null) arg2, if (arg3 != null) arg3]; - + + final args = [ + '⚠️', + message, + if (arg1 != null) arg1, + if (arg2 != null) arg2, + if (arg3 != null) arg3 + ]; + if (kIsWeb) { _consoleWarn(args.join(' ')); } else { @@ -82,11 +93,18 @@ class DebugLogger { /// Log an error message to the console. /// Use tagged format: debugError('[API] Failed to post queue', error); - static void error(String message, [Object? arg1, Object? arg2, Object? arg3]) { + static void error(String message, + [Object? arg1, Object? arg2, Object? arg3]) { if (!_debugEnabled) return; - - final args = ['❌', message, if (arg1 != null) arg1, if (arg2 != null) arg2, if (arg3 != null) arg3]; - + + final args = [ + '❌', + message, + if (arg1 != null) arg1, + if (arg2 != null) arg2, + if (arg3 != null) arg3 + ]; + if (kIsWeb) { _consoleError(args.join(' ')); } else { diff --git a/lib/utils/debug_logger_io.dart b/lib/utils/debug_logger_io.dart index d26799da..21fa3070 100644 --- a/lib/utils/debug_logger_io.dart +++ b/lib/utils/debug_logger_io.dart @@ -10,6 +10,4 @@ // debugError('[TAG] error'); // ``` -export 'debug_logger_stub.dart' - if (dart.library.html) 'debug_logger.dart'; - +export 'debug_logger_stub.dart' if (dart.library.html) 'debug_logger.dart'; diff --git a/lib/utils/debug_logger_stub.dart b/lib/utils/debug_logger_stub.dart index 4dc5a98d..c702fc74 100644 --- a/lib/utils/debug_logger_stub.dart +++ b/lib/utils/debug_logger_stub.dart @@ -22,7 +22,7 @@ class DebugLogger { // Enable debug logging by default on all builds _debugEnabled = true; - + if (_debugEnabled) { debugPrint('[DEBUG] Debug logging ENABLED (debug mode)'); } @@ -39,7 +39,12 @@ class DebugLogger { /// Log a general info message to the console. /// Use tagged format: debugLog('[BLE] Connected to device'); static void log(String message, [Object? arg1, Object? arg2, Object? arg3]) { - final args = [message, if (arg1 != null) arg1, if (arg2 != null) arg2, if (arg3 != null) arg3]; + final args = [ + message, + if (arg1 != null) arg1, + if (arg2 != null) arg2, + if (arg3 != null) arg3 + ]; final formattedMessage = args.join(' '); // Console logging only in debug mode @@ -54,7 +59,13 @@ class DebugLogger { /// Log a warning message to the console. /// Use tagged format: debugWarn('[GPS] Position stale, re-acquiring'); static void warn(String message, [Object? arg1, Object? arg2, Object? arg3]) { - final args = ['⚠️', message, if (arg1 != null) arg1, if (arg2 != null) arg2, if (arg3 != null) arg3]; + final args = [ + '⚠️', + message, + if (arg1 != null) arg1, + if (arg2 != null) arg2, + if (arg3 != null) arg3 + ]; final formattedMessage = args.join(' '); // Console logging only in debug mode @@ -68,8 +79,15 @@ class DebugLogger { /// Log an error message to the console. /// Use tagged format: debugError('[API] Failed to post queue', error); - static void error(String message, [Object? arg1, Object? arg2, Object? arg3]) { - final args = ['❌', message, if (arg1 != null) arg1, if (arg2 != null) arg2, if (arg3 != null) arg3]; + static void error(String message, + [Object? arg1, Object? arg2, Object? arg3]) { + final args = [ + '❌', + message, + if (arg1 != null) arg1, + if (arg2 != null) arg2, + if (arg3 != null) arg3 + ]; final formattedMessage = args.join(' '); // Console logging only in debug mode diff --git a/lib/utils/distance_formatter.dart b/lib/utils/distance_formatter.dart index 63cfc7da..d8fab1ae 100644 --- a/lib/utils/distance_formatter.dart +++ b/lib/utils/distance_formatter.dart @@ -11,6 +11,20 @@ String formatMeters(double meters, {bool isImperial = false}) { return '${meters.toStringAsFixed(0)}m'; } +/// Web-parity distance string for coverage popups (GRID SUMMARY "MAX DIST" and +/// the repeater "Max Range"): "123.26 km" / "150 m" (metric) or "76.55 mi" / +/// "492 ft" (imperial). Mirrors `formatDistance` (dev/index.php:6205) — note the +/// space and 2-decimal km/mi, which differ from [formatMeters]/[formatKilometers]. +String formatCoverageDistance(double meters, {bool isImperial = false}) { + if (isImperial) { + final feet = meters * 3.28084; + if (feet >= 5280) return '${(feet / 5280).toStringAsFixed(2)} mi'; + return '${feet.round()} ft'; + } + if (meters >= 1000) return '${(meters / 1000).toStringAsFixed(2)} km'; + return '${meters.round()} m'; +} + /// Format a distance in kilometers for display /// Returns string like "2.5km" (metric) or "1.6mi" (imperial) String formatKilometers(double kilometers, {bool isImperial = false}) { diff --git a/lib/utils/geo_validation.dart b/lib/utils/geo_validation.dart new file mode 100644 index 00000000..d22a59ba --- /dev/null +++ b/lib/utils/geo_validation.dart @@ -0,0 +1,73 @@ +// Coordinate-validity helpers shared across the GPS, state, and map layers. + +/// Whether [lat]/[lon] are safe to hand to MapLibre's camera. +/// +/// MapLibre's native `LatLng` constructor throws `std::domain_error` on NaN, +/// infinite, or out-of-range (|lat| > 90) coordinates. That C++ exception is +/// uncaught across the C++→Obj-C boundary, so it calls `std::terminate` and +/// aborts the whole app (`SIGABRT`). iOS can briefly report an invalid +/// `CLLocation` (e.g. right after the app resumes from background, or with a +/// negative accuracy), which `geolocator` passes straight through — so every +/// coordinate that can reach the map camera or be persisted/uploaded must be +/// validated against the WGS84 domain first. +bool isValidLatLng(double lat, double lon) => + lat.isFinite && lon.isFinite && lat.abs() <= 90 && lon.abs() <= 180; + +/// Whether a lat/lon bounding box is zero/near-zero area (a single point, or a +/// cluster of coincident points). +/// +/// Fitting the camera to a degenerate box makes MapLibre divide by a ~0 span, +/// producing a non-finite zoom that propagates into its `unproject` math and +/// aborts the app via the same `LatLng` throw as invalid coordinates. Callers +/// must detect this and fall back to a plain center+zoom move instead. +/// [epsilonDeg] defaults to 1e-6° (≈0.1 m) — below this a fit is meaningless. +bool isDegenerateBounds( + double minLat, + double maxLat, + double minLon, + double maxLon, { + double epsilonDeg = 1e-6, +}) => + (maxLat - minLat).abs() < epsilonDeg && + (maxLon - minLon).abs() < epsilonDeg; + +/// Clamp fit-bounds edge padding so it can never meet or exceed the map's +/// rendered size. +/// +/// MapLibre fits a bounds into `size - padding`; if the horizontal or vertical +/// padding sums to ≥ the map dimension the available area goes to zero/negative, +/// yielding a non-finite zoom that aborts the app inside `unproject`. This keeps +/// at least [minVisible] logical pixels visible on each axis, shrinking each +/// side proportionally when the requested padding is too large. When [width] or +/// [height] is unknown (≤ 0, e.g. before first layout) it returns small safe +/// defaults rather than trusting the caller's values. +({double left, double top, double right, double bottom}) clampFitPadding( + double left, + double top, + double right, + double bottom, + double width, + double height, { + double minVisible = 40, +}) { + // Unknown/zero viewport: don't trust the requested padding at all. + if (!width.isFinite || !height.isFinite || width <= 0 || height <= 0) { + return (left: 8, top: 8, right: 8, bottom: 8); + } + + ({double a, double b}) clampPair(double a, double b, double extent) { + a = a.isFinite && a > 0 ? a : 0; + b = b.isFinite && b > 0 ? b : 0; + final budget = extent - minVisible; + if (budget <= 0) return (a: 0, b: 0); + final sum = a + b; + if (sum <= budget) return (a: a, b: b); + // Shrink proportionally to fit the budget (sum > 0 here since sum > budget ≥ 0). + final scale = budget / sum; + return (a: a * scale, b: b * scale); + } + + final h = clampPair(left, right, width); + final v = clampPair(top, bottom, height); + return (left: h.a, top: v.a, right: h.b, bottom: v.b); +} diff --git a/lib/utils/map_style_errors.dart b/lib/utils/map_style_errors.dart new file mode 100644 index 00000000..3e6d1712 --- /dev/null +++ b/lib/utils/map_style_errors.dart @@ -0,0 +1,11 @@ +/// Classifiers for MapLibre style-mutation platform errors. +/// +/// Whether a native style object survived an earlier failed teardown is not +/// observable from Dart; the only signal is the add call throwing "already +/// exists" (Android `CannotAddLayerException` / `CannotAddSourceException`, +/// surfaced as a PlatformException). Install paths use this to ADOPT the +/// existing object instead of reporting failure: reporting failure left the +/// installed-flag false forever, and the still-rendering native layer froze +/// at its last pushed data (#482, the stuck GPS arrow). +bool mapStyleObjectAlreadyExists(Object error) => + error.toString().toLowerCase().contains('already exists'); diff --git a/lib/utils/mvt_cells.dart b/lib/utils/mvt_cells.dart new file mode 100644 index 00000000..1ded7855 --- /dev/null +++ b/lib/utils/mvt_cells.dart @@ -0,0 +1,159 @@ +import 'dart:typed_data'; + +/// Minimal decoder for MeshMapper's coverage vector tiles (vector_tile.php) — +/// just enough of the MVT/protobuf wire format to extract each cell's +/// `{id, i, j, st}`. Geometry is skipped entirely: a cell's rectangle is +/// reconstructed from its grid indices (i, j) and the grid step table, which +/// is byte-identical to what the server encoded (both compute corners from +/// indices). Contract reference: MeshMapper_Server/docs/VECTOR_TILES.md. +/// +/// WEB-SAFE BY DESIGN: feature ids run up to ~2^42 and dart2js truncates +/// bitwise ops to 32 bits, so varint/zigzag decoding here uses arithmetic +/// (`*`, `~/`, `%`) only — same rule as WireTagCodec. + +/// gsize (metres) -> [latStep, lonStep] degrees. KEEP IN SYNC with +/// `coverageGridPresets()` in MeshMapper_Server dev/coverage_cells.php +/// (the app only ever uses the 300/100 presets). +const Map> kCoverageGridSteps = { + 300: [0.0027, 0.00384], + 100: [0.0009, 0.00128], +}; + +/// One coverage cell from a decoded tile. `st` is the server's status +/// category (1=green 2=cyan 3=orange 4=purple 5=grey 6=red). +class CoverageCell { + final int id; + final int i; + final int j; + final int st; + const CoverageCell(this.id, this.i, this.j, this.st); +} + +class _Reader { + final Uint8List buf; + int pos = 0; + _Reader(this.buf); + + bool get done => pos >= buf.length; + + /// Varint as a Dart int via arithmetic (exact up to 2^53 — ids are < 2^53). + int varint() { + var value = 0; + var multiplier = 1; + while (true) { + final b = buf[pos++]; + value += (b % 128) * multiplier; + if (b < 128) return value; + multiplier *= 128; + } + } + + Uint8List bytes(int len) { + final out = Uint8List.sublistView(buf, pos, pos + len); + pos += len; + return out; + } +} + +int _unzigzag(int n) => n.isOdd ? -((n + 1) ~/ 2) : n ~/ 2; + +/// Decodes the cells of a single-layer coverage MVT (uncompressed bytes — +/// package:http has already gunzipped the response). Returns const [] for +/// anything that doesn't parse as expected: a tile we can't read must never +/// break the wardriving flow, the patch just skips it. +List decodeCoverageCells(Uint8List mvt) { + try { + final tile = _Reader(mvt); + while (!tile.done) { + final tag = tile.varint(); + final field = tag ~/ 8; + final wire = tag % 8; + if (field == 3 && wire == 2) { + return _decodeLayer(_Reader(tile.bytes(tile.varint()))); + } + _skip(tile, wire); + } + } catch (_) { + // Malformed/foreign tile — treated as empty below. + } + return const []; +} + +List _decodeLayer(_Reader layer) { + final keys = []; + final values = []; + final features = <_Reader>[]; + while (!layer.done) { + final tag = layer.varint(); + final field = tag ~/ 8; + final wire = tag % 8; + if (field == 2 && wire == 2) { + features.add(_Reader(layer.bytes(layer.varint()))); + } else if (field == 3 && wire == 2) { + keys.add(String.fromCharCodes(layer.bytes(layer.varint()))); + } else if (field == 4 && wire == 2) { + values.add(_decodeValue(_Reader(layer.bytes(layer.varint())))); + } else { + _skip(layer, wire); + } + } + + final cells = []; + for (final f in features) { + var id = 0; + final tags = []; + while (!f.done) { + final tag = f.varint(); + final field = tag ~/ 8; + final wire = tag % 8; + if (field == 1 && wire == 0) { + id = f.varint(); + } else if (field == 2 && wire == 2) { + final packed = _Reader(f.bytes(f.varint())); + while (!packed.done) { + tags.add(packed.varint()); + } + } else { + _skip(f, wire); + } + } + int? i, j, st; + for (var t = 0; t + 1 < tags.length; t += 2) { + final key = tags[t] < keys.length ? keys[tags[t]] : ''; + final value = tags[t + 1] < values.length ? values[tags[t + 1]] : 0; + if (key == 'i') i = value; + if (key == 'j') j = value; + if (key == 'st') st = value; + } + if (i != null && j != null && st != null) { + cells.add(CoverageCell(id, i, j, st)); + } + } + return cells; +} + +/// Value message: the server encodes all properties as sint64 (field 6). +int _decodeValue(_Reader value) { + while (!value.done) { + final tag = value.varint(); + final field = tag ~/ 8; + final wire = tag % 8; + if (field == 6 && wire == 0) return _unzigzag(value.varint()); + _skip(value, wire); + } + return 0; +} + +void _skip(_Reader r, int wire) { + if (wire == 0) { + r.varint(); + } else if (wire == 2) { + r.bytes(r.varint()); + } else if (wire == 5) { + r.pos += 4; + } else if (wire == 1) { + r.pos += 8; + } else { + throw const FormatException('unsupported wire type'); + } +} diff --git a/lib/utils/ping_colors.dart b/lib/utils/ping_colors.dart new file mode 100644 index 00000000..001da568 --- /dev/null +++ b/lib/utils/ping_colors.dart @@ -0,0 +1,296 @@ +import 'package:flutter/material.dart'; +import '../utils/debug_logger_io.dart'; + +/// Color vision deficiency types for accessibility. +/// +/// Users select their CVD type in Settings > General > Color Vision. +/// The app adapts all semantic colors (ping types, signal quality, +/// repeater status, noise floor) to a distinguishable palette. +enum ColorVisionType { + none, // Default — current palette + protanopia, // Red-blind (~1% males) + deuteranopia, // Green-blind (~1% males) + tritanopia, // Blue-blind (~0.003%) + achromatopsia, // Total color blindness (monochrome) +} + +/// Immutable palette holding every semantic color the app uses. +class ColorPalette { + // Ping type colors + final Color txSuccess; + final Color txSuccessLegend; + final Color txFail; + final Color rx; + final Color discSuccess; + final Color discFail; + final Color traceSuccess; + final Color noResponse; + + // Signal quality (SNR/RSSI) traffic-light + final Color signalGood; + final Color signalMedium; + final Color signalBad; + + // Repeater status on map + final Color repeaterActive; + final Color repeaterNew; + final Color repeaterDead; + final Color repeaterDuplicate; + + // Noise floor gradient (good → medium → bad) + final Color noiseFloorGood; + final Color noiseFloorMedium; + final Color noiseFloorBad; + + // Coverage layer legend (must match tile server output for each CVD type) + final Color coverageBidir; + final Color coverageDisc; + final Color coverageTx; + final Color coverageRx; + final Color coverageDead; + final Color coverageDrop; + + const ColorPalette({ + required this.txSuccess, + required this.txSuccessLegend, + required this.txFail, + required this.rx, + required this.discSuccess, + required this.discFail, + required this.traceSuccess, + required this.noResponse, + required this.signalGood, + required this.signalMedium, + required this.signalBad, + required this.repeaterActive, + required this.repeaterNew, + required this.repeaterDead, + required this.repeaterDuplicate, + required this.noiseFloorGood, + required this.noiseFloorMedium, + required this.noiseFloorBad, + required this.coverageBidir, + required this.coverageDisc, + required this.coverageTx, + required this.coverageRx, + required this.coverageDead, + required this.coverageDrop, + }); +} + +/// Concrete palette definitions for each CVD type. +/// +/// Color choices for CVD palettes based on Wong (2011) "Points of view: +/// Color blindness" — Nature Methods. All colors within each palette are +/// mutually distinguishable for the target CVD type. +class ColorPalettes { + ColorPalettes._(); + + /// Default palette — matches original app colors and web map squares. + /// BIDIR=#7EE094, TX=#FD8928, DISC/TRACE=#51D4E9, RX=#7D54C7, + /// DEAD=#9E9689, DROP=#E04F5D + static const none = ColorPalette( + txSuccess: Color(0xFF4CAF50), + txSuccessLegend: Color(0xFF22C55E), + txFail: Color(0xFFF44336), + rx: Color(0xFF7D54C7), + discSuccess: Color(0xFF51D4E9), + discFail: Color(0xFF9E9E9E), + traceSuccess: Color(0xFF00BCD4), + noResponse: Color(0xFF9E9E9E), + signalGood: Colors.green, + signalMedium: Colors.orange, + signalBad: Colors.red, + repeaterActive: Color(0xFFD63384), + repeaterNew: Color(0xFFFD7E14), + repeaterDead: Color(0xFF6C757D), + repeaterDuplicate: Color(0xFFDC3545), + noiseFloorGood: Colors.green, + noiseFloorMedium: Colors.orange, + noiseFloorBad: Colors.red, + coverageBidir: Color(0xFF7EE094), + coverageDisc: Color(0xFF51D4E9), + coverageTx: Color(0xFFFD8928), + coverageRx: Color(0xFF7D54C7), + coverageDead: Color(0xFF9E9689), + coverageDrop: Color(0xFFE04F5D), + ); + + /// Protanopia (red-blind) — replaces red/green axis with blue/orange. + /// Also used for deuteranopia since both are red-green CVD. + static const protanopia = ColorPalette( + txSuccess: Color(0xFF0072B2), // Wong blue + txSuccessLegend: Color(0xFF56B4E9), // Wong sky blue + txFail: Color(0xFFD55E00), // Wong vermillion + rx: Color(0xFFCC79A7), // Wong reddish purple + discSuccess: Color(0xFF56B4E9), // Wong sky blue + discFail: Color(0xFF9E9E9E), // Grey (unchanged) + traceSuccess: Color(0xFF009E73), // Wong bluish green + noResponse: Color(0xFF9E9E9E), // Grey (unchanged) + signalGood: Color(0xFF0072B2), // Blue + signalMedium: Color(0xFFF0E442), // Wong yellow + signalBad: Color(0xFFD55E00), // Vermillion + repeaterActive: Color(0xFFCC79A7), // Reddish purple + repeaterNew: Color(0xFFF0E442), // Yellow + repeaterDead: Color(0xFF9E9E9E), // Grey + repeaterDuplicate: Color(0xFFD55E00), // Vermillion + noiseFloorGood: Color(0xFF0072B2), + noiseFloorMedium: Color(0xFFF0E442), + noiseFloorBad: Color(0xFFD55E00), + coverageBidir: Color(0xFF0072B2), + coverageDisc: Color(0xFF56B4E9), + coverageTx: Color(0xFFE69F00), + coverageRx: Color(0xFFCC79A7), + coverageDead: Color(0xFF9E9E9E), + coverageDrop: Color(0xFFD55E00), + ); + + /// Tritanopia (blue-blind) — replaces blue/cyan with orange/vermillion. + /// Red/green distinction is preserved since tritan users can see those. + static const tritanopia = ColorPalette( + txSuccess: Color(0xFF009E73), // Wong bluish green + txSuccessLegend: Color(0xFF22C55E), // Bright green (visible) + txFail: Color(0xFFD55E00), // Wong vermillion + rx: Color(0xFFCC79A7), // Wong reddish purple + discSuccess: Color(0xFFE69F00), // Wong orange (replaces cyan) + discFail: Color(0xFF9E9E9E), // Grey (unchanged) + traceSuccess: Color(0xFFD55E00), // Vermillion (replaces cyan) + noResponse: Color(0xFF9E9E9E), // Grey (unchanged) + signalGood: Color(0xFF009E73), // Bluish green + signalMedium: Color(0xFFE69F00), // Orange + signalBad: Color(0xFFD55E00), // Vermillion + repeaterActive: Color(0xFFCC79A7), // Reddish purple + repeaterNew: Color(0xFFE69F00), // Orange + repeaterDead: Color(0xFF9E9E9E), // Grey + repeaterDuplicate: Color(0xFFD55E00), // Vermillion + noiseFloorGood: Color(0xFF009E73), + noiseFloorMedium: Color(0xFFE69F00), + noiseFloorBad: Color(0xFFD55E00), + coverageBidir: Color(0xFF009E73), + coverageDisc: Color(0xFFE69F00), + coverageTx: Color(0xFFCC79A7), + coverageRx: Color(0xFFCC79A7), + coverageDead: Color(0xFF9E9E9E), + coverageDrop: Color(0xFFD55E00), + ); + + /// Achromatopsia (monochrome) — luminance-only palette. + /// Relies on maximum brightness contrast between categories. + /// Secondary indicators (icons, text) are essential with this palette. + static const achromatopsia = ColorPalette( + txSuccess: Color(0xFFE0E0E0), // Light + txSuccessLegend: Color(0xFFE0E0E0), + txFail: Color(0xFF616161), // Dark + rx: Color(0xFF9E9E9E), // Medium + discSuccess: Color(0xFFBDBDBD), // Medium-light + discFail: Color(0xFF757575), // Medium-dark + traceSuccess: Color(0xFF757575), // Medium-dark + noResponse: Color(0xFF616161), // Dark + signalGood: Color(0xFFE0E0E0), // Light + signalMedium: Color(0xFF9E9E9E), // Medium + signalBad: Color(0xFF424242), // Very dark + repeaterActive: Color(0xFFE0E0E0), // Light + repeaterNew: Color(0xFFBDBDBD), // Medium-light + repeaterDead: Color(0xFF616161), // Dark + repeaterDuplicate: Color(0xFF424242), // Very dark + noiseFloorGood: Color(0xFFE0E0E0), + noiseFloorMedium: Color(0xFF9E9E9E), + noiseFloorBad: Color(0xFF424242), + coverageBidir: Color(0xFFE0E0E0), + coverageDisc: Color(0xFFBDBDBD), + coverageTx: Color(0xFF9E9E9E), + coverageRx: Color(0xFF757575), + coverageDead: Color(0xFF616161), + coverageDrop: Color(0xFF424242), + ); + + /// Look up palette for a given CVD type. + static ColorPalette forType(ColorVisionType type) { + return switch (type) { + ColorVisionType.none => none, + ColorVisionType.protanopia => protanopia, + ColorVisionType.deuteranopia => protanopia, // Same as protanopia + ColorVisionType.tritanopia => tritanopia, + ColorVisionType.achromatopsia => achromatopsia, + }; + } +} + +/// Centralized color accessors for ping types, signal quality, repeater +/// status, and noise floor. +/// +/// All getters delegate to the active [ColorPalette], which changes when the +/// user selects a different color vision type in Settings. Existing call +/// sites (`PingColors.txSuccess`, etc.) continue to work unchanged. +class PingColors { + PingColors._(); + + static ColorPalette _activePalette = ColorPalettes.none; + static ColorVisionType _currentType = ColorVisionType.none; + + /// Set the active palette. Called by AppStateProvider when the + /// colorVisionType preference changes or on app startup. + static void setColorVisionType(ColorVisionType type) { + _currentType = type; + _activePalette = ColorPalettes.forType(type); + debugLog('[A11Y] Color palette set to ${type.name}'); + } + + /// Current CVD type (for UI display in settings). + static ColorVisionType get currentType => _currentType; + + // ── Ping type colors (same API as before) ── + static Color get txSuccess => _activePalette.txSuccess; + static Color get txSuccessLegend => _activePalette.txSuccessLegend; + static Color get txFail => _activePalette.txFail; + static Color get rx => _activePalette.rx; + static Color get discSuccess => _activePalette.discSuccess; + static Color get discFail => _activePalette.discFail; + static Color get traceSuccess => _activePalette.traceSuccess; + static Color get noResponse => _activePalette.noResponse; + + // ── Signal quality (SNR/RSSI traffic-light) ── + static Color get signalGood => _activePalette.signalGood; + static Color get signalMedium => _activePalette.signalMedium; + static Color get signalBad => _activePalette.signalBad; + + // ── Repeater status ── + static Color get repeaterActive => _activePalette.repeaterActive; + static Color get repeaterNew => _activePalette.repeaterNew; + static Color get repeaterDead => _activePalette.repeaterDead; + static Color get repeaterDuplicate => _activePalette.repeaterDuplicate; + + // ── Noise floor gradient ── + static Color get noiseFloorGood => _activePalette.noiseFloorGood; + static Color get noiseFloorMedium => _activePalette.noiseFloorMedium; + static Color get noiseFloorBad => _activePalette.noiseFloorBad; + + // ── Coverage layer legend (matches tile server output) ── + static Color get coverageBidir => _activePalette.coverageBidir; + static Color get coverageDisc => _activePalette.coverageDisc; + static Color get coverageTx => _activePalette.coverageTx; + static Color get coverageRx => _activePalette.coverageRx; + static Color get coverageDead => _activePalette.coverageDead; + static Color get coverageDrop => _activePalette.coverageDrop; + + // ── Convenience: SNR color from value ── + static Color snrColor(double snr) { + if (snr <= -1) return signalBad; + if (snr <= 5) return signalMedium; + return signalGood; + } + + // ── Convenience: RSSI color from value ── + static Color rssiColor(int rssi) { + if (rssi >= -70) return signalGood; + if (rssi >= -100) return signalMedium; + return signalBad; + } + + // ── Convenience: Noise floor color from dBm ── + static Color noiseFloorColor(double dbm) { + if (dbm <= -100) return noiseFloorGood; + if (dbm <= -90) return noiseFloorMedium; + return noiseFloorBad; + } +} diff --git a/lib/utils/pkce.dart b/lib/utils/pkce.dart new file mode 100644 index 00000000..7e889da9 --- /dev/null +++ b/lib/utils/pkce.dart @@ -0,0 +1,58 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +/// A PKCE verifier/challenge pair plus an independent OAuth `state`, generated +/// for one MyMeshMapper portal sign-in attempt (RFC 7636, S256 only). +/// +/// The portal REQUIRES `code_challenge_method=S256` and rejects a challenge +/// that is not exactly 43 base64url characters, so both values are emitted +/// unpadded. `state` is generated separately from the verifier so that a +/// leaked callback URL (which carries `state`) reveals nothing about the +/// verifier that will redeem the code. +class PkcePair { + /// 43-char base64url (32 random bytes), sent only in the `token` exchange. + final String verifier; + + /// base64url(SHA-256(ASCII(verifier))), sent in the authorize URL. + final String challenge; + + /// 22-char base64url (16 random bytes), echoed back by the portal. + final String state; + + const PkcePair({ + required this.verifier, + required this.challenge, + required this.state, + }); + + /// Generate a fresh pair. [random] exists for tests only — production always + /// uses `Random.secure()`. + static PkcePair generate({Random? random}) { + final rng = random ?? Random.secure(); + final verifier = _base64UrlNoPad(_randomBytes(rng, 32)); + return PkcePair( + verifier: verifier, + challenge: challengeFor(verifier), + state: _base64UrlNoPad(_randomBytes(rng, 16)), + ); + } + + /// RFC 7636 §4.2: `BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))`. + static String challengeFor(String verifier) => _base64UrlNoPad( + Uint8List.fromList(sha256.convert(ascii.encode(verifier)).bytes), + ); + + static Uint8List _randomBytes(Random rng, int count) { + final bytes = Uint8List(count); + for (var i = 0; i < count; i++) { + bytes[i] = rng.nextInt(256); + } + return bytes; + } + + static String _base64UrlNoPad(Uint8List bytes) => + base64UrlEncode(bytes).replaceAll('=', ''); +} diff --git a/lib/utils/repeater_format.dart b/lib/utils/repeater_format.dart new file mode 100644 index 00000000..2d6c9d5c --- /dev/null +++ b/lib/utils/repeater_format.dart @@ -0,0 +1,68 @@ +/// Date / clock-skew formatters ported from the web client (`dev/index.php`) so the +/// app's repeater detail sheet matches the web popup text exactly. +/// +/// The web stores timestamps as either Unix seconds or milliseconds; values below +/// 2e10 are treated as seconds (mirrors `formatDateOnly` / `calculateDaysAgo`). +library; + +DateTime? _toDateTime(num? ts) { + if (ts == null) return null; + var v = ts.toDouble(); + if (v <= 0) return null; + if (v < 20000000000) { + v = v * 1000; // seconds -> ms (web: `if (ts < 20000000000) ts *= 1000`) + } + return DateTime.fromMillisecondsSinceEpoch(v.toInt()); +} + +/// `MM/DD/YY` (e.g. `06/16/26`). Port of `formatDateOnly` (`dev/index.php:9907`). +String formatDateOnly(num? ts) { + final d = _toDateTime(ts); + if (d == null) return 'N/A'; + final mm = d.month.toString().padLeft(2, '0'); + final dd = d.day.toString().padLeft(2, '0'); + final yy = (d.year % 100).toString().padLeft(2, '0'); + return '$mm/$dd/$yy'; +} + +/// `Today` / `1 day ago` / `N days ago`. Port of `calculateDaysAgo` +/// (`dev/index.php:9931`) — both timestamps compared at local midnight. +String daysAgo(num? ts) { + final d = _toDateTime(ts); + if (d == null) return ''; + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final that = DateTime(d.year, d.month, d.day); + final diff = today.difference(that).inDays; + if (diff == 0) return 'Today'; + if (diff == 1) return '1 day ago'; + return '$diff days ago'; +} + +/// `MM/DD/YY (Today)` style — the date plus its parenthesised age, matching the +/// web's `formatDateOnly(ts) + ' (' + calculateDaysAgo(ts) + ')'`. +String formatDateWithAgo(num? ts) { + if (ts == null) return 'N/A'; + final ago = daysAgo(ts); + return ago.isEmpty ? formatDateOnly(ts) : '${formatDateOnly(ts)} ($ago)'; +} + +/// Human-readable clock skew, e.g. `49.4 minutes ahead`. Port of the warning text +/// in `generateRepeaterPopup` (`dev/index.php:12757`). +/// +/// Returns `null` when the offset is null or within the ±120 s tolerance (no +/// warning shown). `offset > 0` ⇒ repeater clock is *behind*; `< 0` ⇒ *ahead*. +String? humanizeClockSkew(int? offsetSecs) { + if (offsetSecs == null) return null; + final abs = offsetSecs.abs(); + if (abs <= 120) return null; + final String mag; + if (abs >= 86400) { + mag = '${(abs / 86400).toStringAsFixed(1)} days'; + } else if (abs >= 3600) { + mag = '${(abs / 3600).toStringAsFixed(1)} hours'; + } else { + mag = '${(abs / 60).toStringAsFixed(1)} minutes'; + } + return '$mag ${offsetSecs > 0 ? 'behind' : 'ahead'}'; +} diff --git a/lib/utils/serial_task_gate.dart b/lib/utils/serial_task_gate.dart new file mode 100644 index 00000000..efa0dba0 --- /dev/null +++ b/lib/utils/serial_task_gate.dart @@ -0,0 +1,25 @@ +import 'dart:async'; + +/// Runs queued async tasks strictly one at a time, in submission order. +/// +/// MapLibre style mutations (add/remove source and layer) are multi-await +/// sequences guarded by Dart-side "ready" flags. Two flows interleaving +/// between a flag check and the final await double-add or half-remove native +/// objects: the zone-transfer overlay rebuild racing the post-upload patch +/// refresh produced `CannotAddSourceException: Source meshmapper-coverage-patch +/// already exists` at a region boundary (#495). Funnelling every mutating flow +/// through one gate makes each flow atomic with respect to the others. +/// +/// A task's error propagates to its own submitter only; the queue keeps +/// draining behind it. Do NOT await a nested [run] from inside a running task: +/// it is queued after the current task and awaiting it would deadlock. +class SerialTaskGate { + Future _tail = Future.value(); + + /// Queue [task] behind everything already submitted and return its result. + Future run(Future Function() task) { + final result = _tail.then((_) => task()); + _tail = result.then((_) {}, onError: (_) {}); + return result; + } +} diff --git a/lib/widgets/bug_report_dialog.dart b/lib/widgets/bug_report_dialog.dart index 9c34d2ba..ecf1c020 100644 --- a/lib/widgets/bug_report_dialog.dart +++ b/lib/widgets/bug_report_dialog.dart @@ -165,7 +165,8 @@ class _BugReportSheetState extends State { 'not-connected'; // Use last connected device name (companion name without MeshCore- prefix) - final deviceName = widget.appState.lastConnectedDeviceName ?? 'not-connected'; + final deviceName = + widget.appState.lastConnectedDeviceName ?? 'not-connected'; // Format description with username if provided final username = _usernameController.text.trim(); @@ -193,7 +194,8 @@ class _BugReportSheetState extends State { if (!mounted) return; if (result.success) { - debugLog('[BUG REPORT] Report submitted successfully: ${result.issueUrl}'); + debugLog( + '[BUG REPORT] Report submitted successfully: ${result.issueUrl}'); Navigator.of(context).pop(result); } else { setState(() { @@ -245,13 +247,15 @@ class _BugReportSheetState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), child: Row( children: [ - Icon(Icons.feedback_outlined, color: theme.colorScheme.primary, size: 28), + Icon(Icons.feedback_outlined, + color: theme.colorScheme.primary, size: 28), const SizedBox(width: 12), Text('Submit Feedback', style: theme.textTheme.titleLarge), const Spacer(), IconButton( icon: const Icon(Icons.close), - onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), + onPressed: + _isSubmitting ? null : () => Navigator.of(context).pop(), tooltip: 'Close', ), ], @@ -270,272 +274,295 @@ class _BugReportSheetState extends State { child: ListView( controller: widget.scrollController, padding: const EdgeInsets.all(20), - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - children: [ - // Ticket type selector - SegmentedButton - _buildSectionLabel(theme, Icons.category, 'Report Type'), - const SizedBox(height: 8), - SegmentedButton( - segments: const [ - ButtonSegment( - value: 'bug', - label: Text('Bug'), - icon: Icon(Icons.bug_report, size: 18), - ), - ButtonSegment( - value: 'enhancement', - label: Text('Feature'), - icon: Icon(Icons.lightbulb_outline, size: 18), - ), - ], - selected: {_ticketType}, - onSelectionChanged: _isSubmitting - ? null - : (selected) => setState(() => _ticketType = selected.first), - showSelectedIcon: false, - ), - const SizedBox(height: 24), - - // Username field (optional, auto-populated from remembered device) - _buildSectionLabel(theme, Icons.person, 'Username (optional)'), - const SizedBox(height: 8), - TextFormField( - controller: _usernameController, - textCapitalization: TextCapitalization.words, - decoration: _buildInputDecoration( - theme, - hintText: 'Your MeshCore companion name', - ), - maxLength: 50, - enabled: !_isSubmitting, - ), - const SizedBox(height: 16), - - // Title field - _buildSectionLabel(theme, Icons.title, 'Title'), - const SizedBox(height: 8), - TextFormField( - controller: _titleController, - textCapitalization: TextCapitalization.sentences, - decoration: _buildInputDecoration( - theme, - hintText: 'Brief summary of the issue', + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + children: [ + // Ticket type selector - SegmentedButton + _buildSectionLabel(theme, Icons.category, 'Report Type'), + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment( + value: 'bug', + label: Text('Bug'), + icon: Icon(Icons.bug_report, size: 18), + ), + ButtonSegment( + value: 'enhancement', + label: Text('Feature'), + icon: Icon(Icons.lightbulb_outline, size: 18), + ), + ], + selected: {_ticketType}, + onSelectionChanged: _isSubmitting + ? null + : (selected) => + setState(() => _ticketType = selected.first), + showSelectedIcon: false, ), - maxLength: 100, - enabled: !_isSubmitting, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Title is required'; - } - if (value.trim().length < 5) { - return 'Title must be at least 5 characters'; - } - return null; - }, - ), - const SizedBox(height: 16), - - // Description field - _buildSectionLabel(theme, Icons.description, 'Description'), - const SizedBox(height: 8), - TextFormField( - controller: _descriptionController, - textCapitalization: TextCapitalization.sentences, - decoration: _buildInputDecoration( - theme, - hintText: 'Describe the issue or feature request...', - alignLabelWithHint: true, + const SizedBox(height: 24), + + // Username field (optional, auto-populated from remembered device) + _buildSectionLabel( + theme, Icons.person, 'Username (optional)'), + const SizedBox(height: 8), + TextFormField( + controller: _usernameController, + textCapitalization: TextCapitalization.words, + decoration: _buildInputDecoration( + theme, + hintText: 'Your MeshCore companion name', + ), + maxLength: 50, + enabled: !_isSubmitting, ), - maxLines: 5, - maxLength: 2000, - enabled: !_isSubmitting, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Description is required'; - } - if (value.trim().length < 20) { - return 'Please provide more detail (at least 20 characters)'; - } - return null; - }, - ), - const SizedBox(height: 16), - - // Platform selector - _buildSectionLabel(theme, Icons.devices, 'Platform'), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: [ - _buildPlatformChip(theme, 'App', 'app', Icons.phone_android), - _buildPlatformChip(theme, 'Map', 'map', Icons.map), - _buildPlatformChip(theme, 'Other', 'other', Icons.more_horiz), - ], - ), + const SizedBox(height: 16), - // Debug logs section (mobile only) - if (!kIsWeb && _isLoadingFiles) ...[ - const SizedBox(height: 24), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: theme.colorScheme.outline.withValues(alpha: 0.3), - ), + // Title field + _buildSectionLabel(theme, Icons.title, 'Title'), + const SizedBox(height: 8), + TextFormField( + controller: _titleController, + textCapitalization: TextCapitalization.sentences, + decoration: _buildInputDecoration( + theme, + hintText: 'Brief summary of the issue', ), - child: Row( - children: [ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: theme.colorScheme.primary, - ), - ), - const SizedBox(width: 12), - Text( - 'Preparing log files...', - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], + maxLength: 100, + enabled: !_isSubmitting, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Title is required'; + } + if (value.trim().length < 5) { + return 'Title must be at least 5 characters'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Description field + _buildSectionLabel(theme, Icons.description, 'Description'), + const SizedBox(height: 8), + TextFormField( + controller: _descriptionController, + textCapitalization: TextCapitalization.sentences, + decoration: _buildInputDecoration( + theme, + hintText: 'Describe the issue or feature request...', + alignLabelWithHint: true, ), + maxLines: 5, + maxLength: 2000, + enabled: !_isSubmitting, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Description is required'; + } + if (value.trim().length < 20) { + return 'Please provide more detail (at least 20 characters)'; + } + return null; + }, ), - ], - // Debug logs section - always visible when files available - if (!kIsWeb && !_isLoadingFiles && _availableLogFiles.isNotEmpty) ...[ - const SizedBox(height: 24), - _buildSectionLabel(theme, Icons.description, 'Debug Logs'), + const SizedBox(height: 16), + + // Platform selector + _buildSectionLabel(theme, Icons.devices, 'Platform'), const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: theme.colorScheme.outline.withValues(alpha: 0.3), + Wrap( + spacing: 8, + children: [ + _buildPlatformChip( + theme, 'App', 'app', Icons.phone_android), + _buildPlatformChip(theme, 'Map', 'map', Icons.map), + _buildPlatformChip( + theme, 'Other', 'other', Icons.more_horiz), + ], + ), + + // Debug logs section (mobile only) + if (!kIsWeb && _isLoadingFiles) ...[ + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: + theme.colorScheme.outline.withValues(alpha: 0.3), + ), ), - ), - child: Column( - children: [ - // Header with attach toggle - SwitchListTile( - title: const Text('Include with feedback'), - subtitle: Text( - 'Select logs to attach to this report', - style: theme.textTheme.bodySmall?.copyWith( + child: Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: 12), + Text( + 'Preparing log files...', + style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), - value: _uploadLogs, - onChanged: _isSubmitting - ? null - : (value) { - setState(() { - _uploadLogs = value; - if (!_uploadLogs) { - _selectedLogFiles.clear(); - } - }); - }, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - ), - Divider( - height: 1, - color: theme.colorScheme.outline.withValues(alpha: 0.3), + ], + ), + ), + ], + // Debug logs section - always visible when files available + if (!kIsWeb && + !_isLoadingFiles && + _availableLogFiles.isNotEmpty) ...[ + const SizedBox(height: 24), + _buildSectionLabel(theme, Icons.description, 'Debug Logs'), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: + theme.colorScheme.outline.withValues(alpha: 0.3), ), - // Log file list - only shown when toggle is on - if (_uploadLogs) - ...List.generate(_availableLogFiles.length, (index) { - final file = _availableLogFiles[index]; - final filename = file.path.split('/').last; - final sizeBytes = file.lengthSync(); - final isSelected = _selectedLogFiles.contains(file.path); - - // Format size and show part count for oversized files - String sizeDisplay; - final partCount = DebugFileLogger.estimatePartCount(sizeBytes); - if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { - final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); - sizeDisplay = '$sizeMb MB ($partCount parts)'; - } else { - sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; - } - - return ListTile( - dense: true, - leading: Checkbox( - value: isSelected, - onChanged: _isSubmitting - ? null - : (_) => _toggleFile(file.path), - ), - title: Text( - filename, - style: const TextStyle(fontSize: 13), + ), + child: Column( + children: [ + // Header with attach toggle + SwitchListTile( + title: const Text('Include with feedback'), + subtitle: Text( + 'Select logs to attach to this report', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), - trailing: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), + ), + value: _uploadLogs, + onChanged: _isSubmitting + ? null + : (value) { + setState(() { + _uploadLogs = value; + if (!_uploadLogs) { + _selectedLogFiles.clear(); + } + }); + }, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 4), + ), + Divider( + height: 1, + color: theme.colorScheme.outline + .withValues(alpha: 0.3), + ), + // Log file list - only shown when toggle is on + if (_uploadLogs) + ...List.generate(_availableLogFiles.length, + (index) { + final file = _availableLogFiles[index]; + final filename = file.path.split('/').last; + final sizeBytes = file.lengthSync(); + final isSelected = + _selectedLogFiles.contains(file.path); + + // Format size and show part count for oversized files + String sizeDisplay; + final partCount = + DebugFileLogger.estimatePartCount(sizeBytes); + if (sizeBytes >= + DebugFileLogger.maxUploadSizeBytes) { + final sizeMb = (sizeBytes / 1024 / 1024) + .toStringAsFixed(1); + sizeDisplay = '$sizeMb MB ($partCount parts)'; + } else { + sizeDisplay = + '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; + } + + return ListTile( + dense: true, + leading: Checkbox( + value: isSelected, + onChanged: _isSubmitting + ? null + : (_) => _toggleFile(file.path), ), - child: Text( - sizeDisplay, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + title: Text( + filename, + style: const TextStyle(fontSize: 13), + ), + trailing: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme + .colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + sizeDisplay, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), ), - ), - onTap: _isSubmitting ? null : () => _toggleFile(file.path), - ); - }), - ], - ), - ), - ], - - // Error message - if (_errorMessage != null) ...[ - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.error.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: theme.colorScheme.error.withValues(alpha: 0.3), + onTap: _isSubmitting + ? null + : () => _toggleFile(file.path), + ); + }), + ], ), ), - child: Row( - children: [ - Icon( - Icons.error_outline, - size: 20, - color: theme.colorScheme.error, + ], + + // Error message + if (_errorMessage != null) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.error.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.error.withValues(alpha: 0.3), ), - const SizedBox(width: 8), - Expanded( - child: Text( - _errorMessage!, - style: TextStyle(color: theme.colorScheme.error), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + size: 20, + color: theme.colorScheme.error, ), - ), - ], + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle(color: theme.colorScheme.error), + ), + ), + ], + ), ), - ), - ], + ], - // Bottom padding for safe area - SizedBox(height: MediaQuery.of(context).padding.bottom + 80), - ], + // Bottom padding for safe area + SizedBox(height: MediaQuery.of(context).padding.bottom + 80), + ], + ), ), ), ), - ), // Sticky bottom action bar Container( @@ -557,7 +584,8 @@ class _BugReportSheetState extends State { children: [ Expanded( child: OutlinedButton( - onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), + onPressed: + _isSubmitting ? null : () => Navigator.of(context).pop(), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 12), ), @@ -613,7 +641,8 @@ class _BugReportSheetState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), child: Row( children: [ - Icon(Icons.feedback_outlined, color: theme.colorScheme.primary, size: 28), + Icon(Icons.feedback_outlined, + color: theme.colorScheme.primary, size: 28), const SizedBox(width: 12), Text('Submitting...', style: theme.textTheme.titleLarge), ], @@ -635,7 +664,8 @@ class _BugReportSheetState extends State { width: 80, height: 80, decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3), + color: theme.colorScheme.primaryContainer + .withValues(alpha: 0.3), shape: BoxShape.circle, ), child: Center( @@ -653,7 +683,9 @@ class _BugReportSheetState extends State { // Status text Text( - _progressStatus.isNotEmpty ? _progressStatus : 'Please wait...', + _progressStatus.isNotEmpty + ? _progressStatus + : 'Please wait...', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), @@ -680,7 +712,8 @@ class _BugReportSheetState extends State { borderRadius: BorderRadius.circular(6), child: LinearProgressIndicator( value: _progress, - backgroundColor: theme.colorScheme.surfaceContainerHighest, + backgroundColor: + theme.colorScheme.surfaceContainerHighest, color: theme.colorScheme.primary, minHeight: 8, ), diff --git a/lib/widgets/cell_summary_sheet.dart b/lib/widgets/cell_summary_sheet.dart new file mode 100644 index 00000000..a2148e49 --- /dev/null +++ b/lib/widgets/cell_summary_sheet.dart @@ -0,0 +1,320 @@ +import 'package:flutter/material.dart'; + +import '../utils/coverage_summary.dart'; +import '../utils/distance_formatter.dart'; + +/// Bottom sheet showing the coverage "GRID SUMMARY" for a tapped map cell — the +/// 3×3 stat grid + the proportional bar graph with a separator line, mirroring +/// the web popup (`generateSummaryContent`, dev/index.php:13345). Deliberately +/// excludes the web's PING HISTORY list. +class CellSummarySheet extends StatelessWidget { + /// Resolves to the aggregated summary, or null on fetch failure. The caller + /// fetches the cell's points and computes [GridSummary] off the UI thread. + final Future summaryFuture; + final bool isImperial; + + /// When non-null, a minimize button is shown in the header (collapses the + /// sheet to a pill, like the ping-focus sheets). Null = close-only. + final VoidCallback? onMinimize; + + const CellSummarySheet({ + super.key, + required this.summaryFuture, + required this.isImperial, + this.onMinimize, + }); + + // Web GRID SUMMARY palette (dev/index.php getPalette 'none'). + static const Color _bidir = Color(0xFF1E7E34); + static const Color _tx = Color(0xFFFD7E14); + static const Color _rx = Color(0xFF6F42C1); + static const Color _disc = Color(0xFF17A2B8); + static const Color _dead = Color(0xFF6C757D); + static const Color _drop = Color(0xFFBD2130); + static const Color _maxDistBlue = Color(0xFF007BFF); + static const Color _snrGood = Color(0xFF1E7E34); + static const Color _snrMedium = Color(0xFF856404); + static const Color _snrBad = Color(0xFFBD2130); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: EdgeInsets.fromLTRB( + 20, 20, 20, 24 + MediaQuery.of(context).viewPadding.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Expanded( + child: Text( + 'GRID SUMMARY', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + letterSpacing: 0.5), + ), + ), + if (onMinimize != null) + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: onMinimize, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + color: theme.colorScheme.onSurfaceVariant, + tooltip: 'Minimize', + ), + if (onMinimize != null) const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ), + const SizedBox(height: 8), + FutureBuilder( + future: summaryFuture, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center(child: CircularProgressIndicator()), + ); + } + final s = snap.data; + if (s == null) { + return _note(context, 'Could not load coverage data.'); + } + if (s.total == 0) return _note(context, 'No coverage data here.'); + return _buildSummary(context, s); + }, + ), + ], + ), + ); + } + + Widget _buildSummary(BuildContext context, GridSummary s) { + final theme = Theme.of(context); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Text.rich( + TextSpan( + children: [ + TextSpan( + text: 'Total Pings: ', + style: TextStyle( + fontSize: 12, color: theme.colorScheme.onSurfaceVariant), + ), + TextSpan( + text: '${s.total}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ), + const SizedBox(height: 14), + Row( + children: [ + _cell(context, value: '${s.bidir}', label: 'BIDIR', color: _bidir), + _cell(context, value: '${s.tx}', label: 'TX', color: _tx), + _cell(context, value: '${s.rx}', label: 'RX', color: _rx), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + _cell(context, value: '${s.disc}', label: 'DISC', color: _disc), + _cell(context, value: '${s.dead}', label: 'DEAD', color: _dead), + _cell(context, value: '${s.drop}', label: 'DROP', color: _drop), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + _snrCell(context, s), + _cell( + context, + value: s.maxDistMeters != null + ? formatCoverageDistance(s.maxDistMeters!, + isImperial: isImperial) + : 'N/A', + label: 'MAX DIST', + color: _maxDistBlue, + ), + _cell( + context, + value: s.avgNoise != null ? '${s.avgNoise}' : 'N/A', + valueSuffix: s.avgNoise != null ? ' dBm' : null, + label: 'AVG NOISE', + ), + ], + ), + const SizedBox(height: 16), + _barGraph(context, s), + ], + ); + } + + Widget _cell( + BuildContext context, { + required String value, + required String label, + Color? color, + String? valueSuffix, + Widget? valueWidget, + }) { + final theme = Theme.of(context); + return Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + valueWidget ?? + Text.rich( + TextSpan( + children: [ + TextSpan( + text: value, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: color ?? theme.colorScheme.onSurface, + ), + ), + if (valueSuffix != null) + TextSpan( + text: valueSuffix, + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(height: 2), + Text( + label, + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.3, + ), + ), + ], + ), + ); + } + + Widget _snrCell(BuildContext context, GridSummary s) { + final theme = Theme.of(context); + final bucket = s.snrBucket; + final Widget valueWidget; + if (bucket == null) { + valueWidget = Text( + 'N/A', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface, + ), + ); + } else { + final IconData icon; + final Color color; + switch (bucket) { + case 'good': + icon = Icons.signal_cellular_4_bar; + color = _snrGood; + break; + case 'medium': + icon = Icons.signal_cellular_alt; + color = _snrMedium; + break; + default: + icon = Icons.signal_cellular_alt_1_bar; + color = _snrBad; + } + valueWidget = SizedBox( + height: 24, + child: Icon(icon, size: 22, color: color), + ); + } + return _cell(context, + value: '', label: 'AVG SNR', valueWidget: valueWidget); + } + + Widget _barGraph(BuildContext context, GridSummary s) { + final isDark = Theme.of(context).brightness == Brightness.dark; + // Success segments first (BIDIR, DISC, TX, RX), then a separator "line", + // then fail segments (DEAD, DROP) — flex-weighted by count (web order). + final success = <(Color, int, String)>[ + (_bidir, s.bidir, 'BIDIR'), + (_disc, s.disc, 'DISC/TRACE'), + (_tx, s.tx, 'TX'), + (_rx, s.rx, 'RX'), + ]; + final fail = <(Color, int, String)>[ + (_dead, s.dead, 'DEAD'), + (_drop, s.drop, 'DROP'), + ]; + final hasSuccess = success.any((e) => e.$2 > 0); + final hasFail = fail.any((e) => e.$2 > 0); + if (!hasSuccess && !hasFail) return const SizedBox.shrink(); + + final children = []; + void addSeg((Color, int, String) seg) { + if (seg.$2 <= 0) return; + children.add( + Expanded( + flex: seg.$2, + child: Tooltip( + message: '${seg.$3}: ${seg.$2}', + child: Container(color: seg.$1), + ), + ), + ); + } + + for (final seg in success) { + addSeg(seg); + } + if (hasSuccess && hasFail) { + children.add(Container( + width: 2, + color: isDark ? const Color(0xFFCCCCCC) : const Color(0xFF444444), + )); + } + for (final seg in fail) { + addSeg(seg); + } + + return ClipRRect( + borderRadius: BorderRadius.circular(4), + child: SizedBox(height: 10, child: Row(children: children)), + ); + } + + Widget _note(BuildContext context, String msg) => Padding( + padding: const EdgeInsets.symmetric(vertical: 28), + child: Center( + child: Text( + msg, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ), + ); +} diff --git a/lib/widgets/connection_panel.dart b/lib/widgets/connection_panel.dart index 490d5e87..47c2c9d6 100644 --- a/lib/widgets/connection_panel.dart +++ b/lib/widgets/connection_panel.dart @@ -31,7 +31,8 @@ class ConnectionPanel extends StatelessWidget { return _buildAntennaSelector(context, appState, prefs); } - Widget _buildAntennaSelector(BuildContext context, AppStateProvider appState, prefs) { + Widget _buildAntennaSelector( + BuildContext context, AppStateProvider appState, prefs) { final isSet = prefs.externalAntennaSet; final hasExternal = prefs.externalAntenna; final colorScheme = Theme.of(context).colorScheme; @@ -64,7 +65,8 @@ class ConnectionPanel extends StatelessWidget { child: Icon( Icons.settings_input_antenna, size: 20, - color: isSet ? colorScheme.onSurfaceVariant : Colors.orange.shade700, + color: + isSet ? colorScheme.onSurfaceVariant : Colors.orange.shade700, ), ), const SizedBox(width: 12), @@ -84,7 +86,8 @@ class ConnectionPanel extends StatelessWidget { if (appState.antennaRestoredFromDevice) Text( 'Remembered for ${appState.displayDeviceName}', - style: TextStyle(fontSize: 11, color: colorScheme.onSurfaceVariant), + style: TextStyle( + fontSize: 11, color: colorScheme.onSurfaceVariant), ), ], ), @@ -108,7 +111,8 @@ class ConnectionPanel extends StatelessWidget { onTap: () { debugLog('[UI] External antenna button pressed: No'); appState.updatePreferences( - prefs.copyWith(externalAntenna: false, externalAntennaSet: true), + prefs.copyWith( + externalAntenna: false, externalAntennaSet: true), ); }, ), @@ -119,7 +123,8 @@ class ConnectionPanel extends StatelessWidget { onTap: () { debugLog('[UI] External antenna button pressed: Yes'); appState.updatePreferences( - prefs.copyWith(externalAntenna: true, externalAntennaSet: true), + prefs.copyWith( + externalAntenna: true, externalAntennaSet: true), ); }, ), @@ -153,7 +158,12 @@ class ConnectionPanel extends StatelessWidget { : Colors.transparent, borderRadius: BorderRadius.circular(6), boxShadow: isSelected && !isDark - ? [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 2, offset: const Offset(0, 1))] + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 2, + offset: const Offset(0, 1)) + ] : null, ), child: Text( @@ -162,8 +172,12 @@ class ConnectionPanel extends StatelessWidget { fontSize: 13, fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, color: isSelected - ? (isDark ? Colors.white : const Color(0xFF1E293B)) // slate-800 for light - : (isDark ? const Color(0xFF94A3B8) : const Color(0xFF64748B)), // slate-400/500 + ? (isDark + ? Colors.white + : const Color(0xFF1E293B)) // slate-800 for light + : (isDark + ? const Color(0xFF94A3B8) + : const Color(0xFF64748B)), // slate-400/500 ), ), ), diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 7448452a..904a03ba 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -1,39 +1,342 @@ +import 'dart:async'; +import 'dart:io' show Platform; import 'dart:math' as math; +import 'dart:typed_data'; import 'dart:ui' as ui; -import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map_cancellable_tile_provider/flutter_map_cancellable_tile_provider.dart'; -import 'package:latlong2/latlong.dart'; +import 'package:flutter/services.dart' show MethodChannel; +import 'package:geolocator/geolocator.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:provider/provider.dart'; import '../models/log_entry.dart'; +import '../models/noise_floor_session.dart'; import '../models/ping_data.dart'; import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; +import '../services/gps_service.dart'; +import '../utils/coverage_summary.dart'; +import '../utils/coverage_tile_palette.dart'; import '../utils/debug_logger_io.dart'; +import '../utils/geo_validation.dart'; +import '../utils/mvt_cells.dart'; import '../utils/distance_formatter.dart'; +import '../utils/ping_colors.dart'; +import '../utils/repeater_format.dart'; +import '../utils/map_style_errors.dart'; +import '../utils/serial_task_gate.dart'; +import 'cell_summary_sheet.dart'; import 'repeater_id_chip.dart'; +import 'rx_path_chain.dart'; + +/// Satellite style as inline MapLibre style JSON (ArcGIS raster source). +/// The `glyphs` URL is required because our native symbol layers +/// (repeater cluster count, individual repeater hex IDs, distance labels) +/// use `textField`, and MapLibre iOS wedges its resource loader with +/// NSURLError -1002 if it tries to resolve glyphs against a style that +/// doesn't declare a glyphs URL. +const _satelliteStyleJson = + '{"version":8,"glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","sources":{"satellite":{"type":"raster","tiles":["https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"],"tileSize":256,"maxzoom":17}},"layers":[{"id":"satellite-layer","type":"raster","source":"satellite"}]}'; + +/// Default font stack used for all native text labels (textField property). +/// Available in OpenFreeMap glyph sets (Liberty, Bright, Dark, Positron). +const _defaultFontStack = ['Noto Sans Regular']; + +Future renderGpsMarkerPng(String style) => _renderPainterToPng( + _gpsMarkerPainter(style), + const Size(48, 48), + ); + +bool gpsMarkerFacesHeading(String style) => + style == 'arrow' || style == 'walk' || style == 'chomper'; + +CustomPainter _gpsMarkerPainter(String style) => switch (style) { + 'car' => const _CarMarkerPainter(), + 'bike' => const _BikeMarkerPainter(), + 'boat' => const _BoatMarkerPainter(), + 'walk' => const _WalkMarkerPainter(), + 'chomper' => const _ChomperMarkerPainter(), + _ => const _ArrowPainter(), + }; + +/// Image-name constants for the marker bitmaps registered via +/// `controller.addImage()` and referenced by `SymbolOptions.iconImage`. +/// +/// Repeater shapes have one bitmap per (status color × hop_byte shape) — 12 +/// total. Coverage markers have one bitmap per (ping type × success state) for +/// the user's currently-selected style — 8 total per style preference. GPS +/// marker has one bitmap per style — 6 total. +class _MapImages { + _MapImages._(); + + // Repeater shape bitmaps: status × hop_bytes + // Names: rep_active_1, rep_dead_2, rep_dup_3, etc. + static String repeater(String status, int hopBytes) => + 'rep_${status}_$hopBytes'; + + // Detailed-mode baked repeater CHIP bitmaps: status × hop_bytes × hex label. + // The hex is baked into the icon (no text-field) so overlapping un-clustered + // chips can't have a label detach onto a neighbour's box. One image per + // distinct (status, hop, hex); registered lazily + deduped. See + // _renderRepeaterChipPng / _ensureRepeaterChipImages. + // Names: repchip_active_2_A1B2, etc. + static String repeaterChip(String status, int hopBytes, String hex) => + 'repchip_${status}_${hopBytes}_$hex'; + + static const repeaterStatuses = ['active', 'dead', 'new', 'dup']; + static const repeaterHopBytes = [1, 2, 3]; + + // Coverage marker bitmaps: type × success state + // Names: cov_tx_ok, cov_disc_fail, etc. + static String coverage(String type, bool success) => + 'cov_${type}_${success ? "ok" : "fail"}'; + + static const coverageTypes = ['tx', 'rx', 'disc', 'trace']; + + // GPS marker bitmaps: one per style + // Names: gps_arrow, gps_car, etc. The list of styles lives in + // _registerMapImages where we map each style key to its CustomPainter. + static String gps(String style) => 'gps_$style'; +} + +/// Renders a [CustomPainter] into a PNG byte buffer using `dart:ui`. +/// +/// This is the bridge between our existing Flutter `CustomPainter` marker +/// rendering code and MapLibre's native annotation system. The bytes returned +/// here can be passed to `controller.addImage(name, bytes)` and then referenced +/// by `SymbolOptions.iconImage: name`. The native engine renders the symbol +/// in the same pass as the map tiles, eliminating the Flutter platform-view +/// sync lag that affects widget overlays. +/// +/// [size] is the logical size in pixels — the output bitmap is upscaled by +/// [devicePixelRatio] for crispness on high-DPI screens. Default 3.0 covers +/// Renders a distance-label pill: white text on a semi-transparent rounded +/// rectangle background. Returns the PNG bytes and the logical size (width/ +/// height in logical pixels, NOT device pixels) so the caller can use it for +/// screen-space collision tests. +/// +/// Sized dynamically to the text — the pill grows with longer labels. Uses +/// devicePixelRatio=3.0 to match the other bitmap markers on this map. +Future<({Uint8List bytes, Size size})> _renderDistanceLabelPng( + String text, { + double devicePixelRatio = 3.0, +}) async { + const fontSize = 11.0; + const horizontalPad = 6.0; + const verticalPad = 3.0; + const cornerRadius = 6.0; + + // Measure the text first so we can size the pill to fit. + final textPainter = TextPainter( + text: TextSpan( + text: text, + style: const TextStyle( + fontSize: fontSize, + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + + final logicalWidth = textPainter.width + horizontalPad * 2; + final logicalHeight = textPainter.height + verticalPad * 2; + + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.scale(devicePixelRatio); + + // Background pill. + final bgPaint = Paint()..color = Colors.black.withValues(alpha: 0.72); + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(0, 0, logicalWidth, logicalHeight), + const Radius.circular(cornerRadius), + ), + bgPaint, + ); + + // Subtle light border for separation from dark map backgrounds. + final borderPaint = Paint() + ..color = Colors.white.withValues(alpha: 0.25) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.0; + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(0.5, 0.5, logicalWidth - 1, logicalHeight - 1), + const Radius.circular(cornerRadius), + ), + borderPaint, + ); + + textPainter.paint(canvas, const Offset(horizontalPad, verticalPad)); + + final picture = recorder.endRecording(); + final image = await picture.toImage( + (logicalWidth * devicePixelRatio).round(), + (logicalHeight * devicePixelRatio).round(), + ); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + picture.dispose(); + image.dispose(); + if (byteData == null) { + throw StateError('Failed to encode distance label to PNG bytes'); + } + return ( + bytes: byteData.buffer.asUint8List(), + size: Size(logicalWidth, logicalHeight), + ); +} + +/// Bakes a complete repeater "chip" — the status-colored rounded box plus its +/// centered hex label — into a single PNG, so the label is part of the icon and +/// can never detach onto a neighbouring chip's box (the MapLibre symbol two-pass +/// "all icons, then all glyphs" overlap bug). Used ONLY in Detailed grid mode, +/// where repeaters are un-clustered and can overlap. Simplified mode keeps the +/// cheap shared-glyph text-field path (clustering guarantees ≥50px spacing). +/// +/// Variable width — sized to the measured hex like [_renderDistanceLabelPng]. +/// Baked at devicePixelRatio 3.0 to stay crisp on hi-DPI; rendered with +/// iconSize 1.0 + center anchor. Box visuals mirror [_RepeaterShapePainter] +/// (drop shadow, filled box, 2px white border) so chips match the Simplified +/// shape markers. +Future _renderRepeaterChipPng( + String hex, + Color fill, + double borderRadius, { + double devicePixelRatio = 3.0, +}) async { + const fontSize = 13.0; + const horizontalPad = 8.0; // inside the box, each side + const boxHeight = 26.0; + const shadowBlur = 4.0; + const margin = 5.0; // room around the box for the (blurred, +2px) shadow + + final textPainter = TextPainter( + text: TextSpan( + text: hex, + style: const TextStyle( + fontSize: fontSize, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + + final boxWidth = textPainter.width + horizontalPad * 2; + final logicalWidth = boxWidth + margin * 2; + const logicalHeight = boxHeight + margin * 2; + + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.scale(devicePixelRatio); + + final boxRect = Rect.fromLTWH(margin, margin, boxWidth, boxHeight); + final radius = Radius.circular(borderRadius); + + // Drop shadow (positioned 2px below the box). + canvas.drawRRect( + RRect.fromRectAndRadius(boxRect.shift(const Offset(0, 2)), radius), + Paint() + ..color = Colors.black26 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, shadowBlur), + ); + + // Filled colored box. + canvas.drawRRect( + RRect.fromRectAndRadius(boxRect, radius), + Paint()..color = fill, + ); + + // White border (2px, drawn just inside the box edge). + canvas.drawRRect( + RRect.fromRectAndRadius( + boxRect.deflate(1), + Radius.circular(borderRadius - 1), + ), + Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0, + ); + + // Centered hex label. + textPainter.paint( + canvas, + Offset( + margin + (boxWidth - textPainter.width) / 2, + margin + (boxHeight - textPainter.height) / 2, + ), + ); + + final picture = recorder.endRecording(); + final image = await picture.toImage( + (logicalWidth * devicePixelRatio).round(), + (logicalHeight * devicePixelRatio).round(), + ); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + picture.dispose(); + image.dispose(); + if (byteData == null) { + throw StateError('Failed to encode repeater chip to PNG bytes'); + } + return byteData.buffer.asUint8List(); +} + +Future _renderPainterToPng( + CustomPainter painter, + Size size, { + double devicePixelRatio = 3.0, +}) async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + // Scale the canvas so the painter still draws at logical size, but the + // resulting bitmap has more actual pixels. + canvas.scale(devicePixelRatio); + painter.paint(canvas, size); + final picture = recorder.endRecording(); + final image = await picture.toImage( + (size.width * devicePixelRatio).round(), + (size.height * devicePixelRatio).round(), + ); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + picture.dispose(); + image.dispose(); + if (byteData == null) { + throw StateError('Failed to encode CustomPainter to PNG bytes'); + } + return byteData.buffer.asUint8List(); +} -/// Map style options +/// Map style options. +/// +/// Declaration order matters: it determines the cycle order when the user +/// taps the "switch style" button (see `_cycleMapStyle`). Liberty is first +/// because it's the default for new users. enum MapStyle { + liberty, dark, light, satellite, } extension MapStyleExtension on MapStyle { - /// Convert from stored string preference to MapStyle enum + /// Convert from stored string preference to MapStyle enum. + /// Defaults to Liberty for unknown / unset preferences. static MapStyle fromString(String value) { switch (value) { + case 'dark': + return MapStyle.dark; case 'light': return MapStyle.light; case 'satellite': return MapStyle.satellite; - case 'dark': + case 'liberty': default: - return MapStyle.dark; + return MapStyle.liberty; } } @@ -43,6 +346,8 @@ extension MapStyleExtension on MapStyle { return 'Dark'; case MapStyle.light: return 'Light'; + case MapStyle.liberty: + return 'Liberty'; case MapStyle.satellite: return 'Satellite'; } @@ -54,60 +359,77 @@ extension MapStyleExtension on MapStyle { return Icons.dark_mode; case MapStyle.light: return Icons.light_mode; + case MapStyle.liberty: + return Icons.map; case MapStyle.satellite: return Icons.satellite_alt; } } - String get urlTemplate { + /// MapLibre style URL (or inline JSON for satellite) + String get styleUrl { switch (this) { case MapStyle.dark: - return 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; + return 'https://tiles.openfreemap.org/styles/dark'; case MapStyle.light: - return 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; + return 'https://tiles.openfreemap.org/styles/bright'; + case MapStyle.liberty: + return 'https://tiles.openfreemap.org/styles/liberty'; case MapStyle.satellite: - return 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'; + return _satelliteStyleJson; } } - List? get subdomains { + /// Whether this style can be packaged as an offline region. Satellite uses + /// inline raster JSON which MapLibre's offline downloader doesn't support. + bool get isDownloadable { switch (this) { case MapStyle.dark: - return ['a', 'b', 'c', 'd']; case MapStyle.light: - return null; // OSM doesn't use subdomains anymore + case MapStyle.liberty: + return true; case MapStyle.satellite: - return null; // ArcGIS doesn't use subdomains + return false; } } - /// Whether this style supports retina tiles via {r} placeholder - bool get supportsRetina { - switch (this) { - case MapStyle.dark: - return true; // Carto supports @2x via {r} - case MapStyle.light: - return false; // OSM has no retina support - case MapStyle.satellite: - return false; // ArcGIS has no retina support - } - } + /// Styles offered in the offline download picker. + static List get downloadable => + MapStyle.values.where((s) => s.isDownloadable).toList(); } -/// Custom tile provider that silently handles HTTP errors (404, 503, etc.) -/// instead of flooding the console with exceptions -final class SilentCancellableNetworkTileProvider extends CancellableNetworkTileProvider { - SilentCancellableNetworkTileProvider() : super( - dioClient: Dio( - BaseOptions( - validateStatus: (status) => true, // Accept all status codes - ), - ), - ); +/// Resolved repeater with SNR and ambiguity info for ping focus mode. +/// Line color is based on [snr] (green/yellow/red). When a short hex ID +/// matches multiple repeaters, [ambiguous] is true and the line gets a +/// distinct border to indicate uncertainty. +class _ResolvedRepeater { + final Repeater repeater; + final double? snr; + final bool ambiguous; + const _ResolvedRepeater(this.repeater, this.snr, this.ambiguous); +} + +/// A minimized info popup (cell summary or repeater detail) rendered as a 2-row +/// bottom pill — and the DEFAULT state a tap opens in. [title] is the row-1 +/// identity (icon + name); [statsBuilder] builds the row-2 stat chips (it may +/// use a FutureBuilder for lazily-fetched values). [onReshow] expands to the +/// full detail sheet; [onClose] dismisses it and runs any cleanup (e.g. clearing +/// the cell footprint). Mirrors the ping-focus minimize, but for the tap popups. +class _MinimizedInfoPopup { + final Widget title; + final WidgetBuilder statsBuilder; + final VoidCallback onReshow; + final VoidCallback onClose; + const _MinimizedInfoPopup({ + required this.title, + required this.statsBuilder, + required this.onReshow, + required this.onClose, + }); } /// Map widget with TX/RX markers -/// Uses flutter_map with OpenStreetMap tiles +/// Uses MapLibre GL with OpenFreeMap vector tiles class MapWidget extends StatefulWidget { /// Bottom padding in pixels to account for overlays (e.g., control panel in portrait) /// The map will offset its center point upward by half this value @@ -136,24 +458,83 @@ class MapWidget extends StatefulWidget { State createState() => _MapWidgetState(); } -class _MapWidgetState extends State with TickerProviderStateMixin { - final MapController _mapController = MapController(); +class _MapWidgetState extends State with WidgetsBindingObserver { + MapLibreMapController? _mapController; + + // Tracks the app lifecycle so we can suppress every animateCamera() call + // while the app is not in the foreground OR while the GL surface is + // settling after a state transition. MapLibre Native's + // constrainCameraAndZoomToBounds (PR #2475) calls Projection::unproject + // internally — when the GL surface is degenerate (zero-sized on first + // frame, or not yet restored after iOS background suspension), unproject + // produces NaN and the LatLng constructor throws std::domain_error → + // SIGABRT. Suppressing animations for one frame after style-load and + // after resume lets the surface reach a valid state first. + AppLifecycleState _appLifecycleState = AppLifecycleState.resumed; + bool _cameraAnimationReady = false; + + // Set true the first time the map reports idle (`_onMapIdle`). The map only + // goes idle AFTER it has actually rendered a frame / loaded tiles, so this is + // the throw-safe Dart signal that the native viewport is non-degenerate. The + // one-frame `_cameraAnimationReady` latch above proved insufficient: a bad + // launch (tiles never load → viewport stays degenerate for the whole session) + // kept _cameraAnimationReady=true yet still aborted the very first flyTo. Until + // the map renders, every programmatic camera move is held (and the one-shot + // initial zoom re-attempts on later ticks instead of burning). The native + // viewport guard in the vendored maplibre_gl plugin is the final backstop for + // the case where the map reports idle on a still-degenerate surface. + bool _mapHasRenderedOnce = false; + + bool get _canAnimateCamera => + _appLifecycleState == AppLifecycleState.resumed && + _cameraAnimationReady && + _mapHasRenderedOnce; // Auto-follow GPS like a navigation app bool _autoFollow = false; // Disabled by default - users often zoom out first bool _prefsApplied = false; // Guard to load saved prefs only once bool _isMapReady = false; LatLng? _lastGpsPosition; - bool _hasInitialZoomed = false; // Track if we've done the one-time initial zoom to GPS - bool _hasZoomedToLastKnown = false; // Track if we've zoomed to last known position (before GPS) + bool _hasInitialZoomed = + false; // Track if we've done the one-time initial zoom to GPS + bool _hasZoomedToLastKnown = + false; // Track if we've zoomed to last known position (before GPS) + bool _loggedMpxSanityCheck = + false; // One-time log comparing formula vs MapLibre m/px // Map rotation mode - bool _alwaysNorth = true; // true = north always up, false = rotate with heading + bool _alwaysNorth = + true; // true = north always up, false = rotate with heading double? _lastHeading; // Track last heading for smooth rotation + // Desired camera zoom while auto-follow is active. Set when the user taps + // "center on position" and updated when the user pinch-zooms. Each auto- + // follow GPS tick uses this as the animation target zoom — otherwise a tick + // that arrives during the initial zoom animation cancels it (animateCamera + // replaces in-flight animations), leaving the camera stuck at an + // intermediate zoom and the marker off-center. + double? _autoFollowDesiredZoom; + + // Bearing derivation state. geolocator's Position.heading is only reliable + // at speed — on both Android (Location.getBearing() requires hasBearing() + // and speed > 0) and iOS (CLLocation.course == -1 when invalid) it's + // effectively 0 or -1 when stationary or walking slowly. We keep our own + // anchor-to-current bearing as a fallback so the arrow/walk marker and + // heading-mode map rotation behave correctly at low speeds. + LatLng? _bearingAnchor; // last fix used as the bearing origin + double? _computedHeading; // last known-good bearing in degrees 0..360 + // MeshMapper overlay toggle (on by default) bool _showMeshMapperOverlay = true; + // Region boundary overlay toggle (on by default) + bool _showRegionBorders = true; + + // Repeater pins toggle (on by default). When false, _syncRepeaterSymbols + // pushes an empty FeatureCollection so the pins clear without touching the + // layer styling (see #347). + bool _showRepeaters = true; + // Collapsible map controls in landscape bool _mapControlsExpanded = true; @@ -163,168 +544,782 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Map navigation trigger tracking (from log screen) int _lastNavigationTrigger = 0; - // Smooth animation for map movement - AnimationController? _animationController; - Animation? _animation; - LatLng? _animationStartPosition; - LatLng? _animationEndPosition; - - // Smooth animation for map rotation - AnimationController? _rotationAnimationController; - Animation? _rotationAnimation; - double? _rotationStartAngle; - double? _rotationEndAngle; + // History session map view tracking + bool _wasViewingHistory = false; + + // Ping focus mode — highlight connected repeaters when a marker is tapped + LatLng? _focusedPingLocation; + DateTime? _focusedPingTimestamp; + List<_ResolvedRepeater> _focusedRepeaters = []; + LatLng? _preFocusCenter; + double? _preFocusZoom; + bool _wasAutoFollowBeforeFocus = false; + bool _wasRotatingBeforeFocus = false; // true if heading mode was active + // True while ANY focus-style camera is engaged (ping focus OR a community + // tile/repeater coverage view). The pre-focus snapshot above is saved ONCE on + // the first entry and restored ONCE when the last view closes, so switching + // directly between views animates only once and restores to the original view. + bool _cameraFocusActive = false; + bool _focusPanelMinimized = false; + dynamic _focusedPingSource; // TxPing | RxPing | DiscLogEntry | TraceLogEntry + + // A minimized cell-summary / repeater-detail popup, shown as a tappable pill + // (parity with the ping-focus minimize). Separate from focus state so it can't + // entangle focus mode; null when nothing is minimized. + _MinimizedInfoPopup? _minimizedInfoPopup; + + // MapLibre style and overlay tracking. + // Tracks the zone code we last rendered the coverage overlay for. When the + // zone check succeeds after the style has already loaded (e.g. first check + // failed with gps_inaccurate and a later retry succeeded), _addCoverageOverlay + // would otherwise never re-run and the coverage layer would stay missing. + String? _lastOverlayZoneCode; + // Last coverage overlay opacity we pushed into MapLibre. Compared against + // the current preference in _onCoverageOpacityNotify to detect slider + // changes and apply them live via _applyCoverageOverlayOpacity (no layer + // rebuild needed). + double? _lastAppliedCoverageOpacity; + // Set while _onCoverageOpacityNotify has an apply in flight. + // _applyCoverageOverlayOpacity only records the new value AFTER both awaited + // setLayerProperties calls, and the listener runs on every provider notify + // (GPS at ~2 Hz, the passive-RX storm at 10-20/sec), so without this a single + // slider step fires duplicate platform calls and duplicate log lines for the + // whole round trip. + bool _coverageOpacityApplyInFlight = false; + // Guard flag that coalesces multiple overlay-refresh triggers (zone and + // pref changes) in the same frame into a single post-frame callback. + // Without this, two watchers can schedule concurrent _refreshCoverageOverlay + // runs whose remove/add calls interleave and produce "Source already exists" + // errors in the native log. + bool _coverageRefreshScheduled = false; + + // Coverage overlay IDs: each refresh allocates fresh suffixed IDs so + // a remove+add sequence never collides with a stale native source. + String? _activeCoverageSourceId; + String? _activeCoverageLayerId; + int _coverageBufferCounter = 0; + // Guards against opening two GRID SUMMARY sheets when both the feature-tap and + // the onMapClick hit-test fire for the same coverage cell tap. + bool _cellSummaryShowing = false; + // True from a tile tap until the cell popup (pill OR sheet) is closed — gates + // drawing the footprint highlight so it isn't drawn after a dismiss. Cleared + // in _clearCellHighlight (the teardown for both the pill and the sheet). + bool _cellPopupActive = false; + // Grid size is baked into the tile URL and the CVD palette into the layer + // paint; a change to either rebuilds the overlay via the build watcher. + int? _lastAppliedGridSize; + String? _lastAppliedCvd; + // Session coverage patch: a GeoJSON layer carrying the user's own + // freshly-pinged cells ON TOP of the base overlay; the base layer's copies + // of those cells are hidden via setFilter so translucent fills never stack. + // The base source is never swapped during a session — only the patch + // updates, so nothing visibly changes except the changed cells. + static const String _patchSourceId = 'meshmapper-coverage-patch'; + static const String _patchLayerId = 'meshmapper-coverage-patch-layer'; + bool _patchLayerReady = false; + + /// Serializes every coverage overlay/patch style mutation. These flows are + /// multi-await sequences whose ready-flags are checked before the first + /// await; two of them interleaving (the zone-transfer rebuild racing the + /// post-upload patch refresh) double-added the patch source and crashed at + /// a region boundary (#495), and the same class of race froze the GPS puck + /// when its duplicate install failed permanently (#482). Entry points + /// enqueue; the *Locked bodies and the helpers they call must never + /// enqueue, or they deadlock the gate. + final SerialTaskGate _coverageGate = SerialTaskGate(); + + // Tap-to-highlight overlay: a fill layer painting the clicked cell's + // (2·blob+1)² block (3×3 Detailed / 1 cell Simplified), centred on the tapped + // tile, in one uniform dominant colour while the coverage backdrop dims + // (web highlightSpotCoverage parity). Installed once (empty); populated on a + // cell tap and cleared to empty when the summary sheet closes — no per-tap + // layer churn. + static const String _cellHighlightSourceId = 'meshmapper-cell-highlight'; + static const String _cellHighlightLayerId = 'meshmapper-cell-highlight-layer'; + bool _cellHighlightReady = false; + // While a tapped cell's footprint is shown, the coverage backdrop is dimmed so + // the bright footprint pops (parity with the web's spot-click). True between + // _showCellFootprint and _clearCellHighlight; gates the opacity restore and + // suppresses the build-method opacity-sync (which would otherwise un-dim). + bool _coverageDimmedForCell = false; + // Below this zoom, coverage cells are only a pixel or two wide, so a tile tap + // is almost always accidental — ignore cell taps when zoomed further out (a + // 300 m cell is ~8 px at z11, ~16 px at z12). Tune if needed. + static const double _kMinCellTapZoom = 12.0; + // Same backdrop dim while a selected repeater's coverage cells/lines are shown + // (Feature B, web `drawRepeaterCoverageFromCache` parity). True between + // _drawRepeaterCoverage and _clearRepeaterIsolation; gates the opacity restore + // and suppresses the build-method opacity-sync. + bool _coverageDimmedForRepeater = false; + static const double _kCellHighlightFadeOpacity = 0.15; + int _lastAppliedPatchVersion = -1; + bool _styleLoaded = false; + bool _hasStyleLoadedOnce = + false; // True after first onStyleLoadedCallback (prevents re-centering on style switch) + + // Tracks the last marker data version we synced to native annotations. + // The build() method computes a version hash from app state and only triggers + // _syncAllAnnotations when the hash changes (avoiding unnecessary diff work). + int _lastMarkerDataVersion = -1; + // Serializes concurrent _syncAllAnnotations runs. Without this, a second + // build() can fire a sync while the previous one is still awaiting platform + // calls — both would mutate _coverageSymbols / _distanceLabelSymbols, and + // the older sync's cleanup loop would remove symbols the newer sync just + // added. The flag causes re-entrant post-frame callbacks to bail; after the + // in-flight sync finishes, the finally block checks if the data version + // advanced during the run and triggers a rebuild if so. + bool _syncInFlight = false; + + // GPS marker sync runs on its own gate, separate from _syncAllAnnotations. + // GPS position is camera/sensor state — it changes every tick during + // auto-follow but the marker data (repeaters, pings, focus) does not. + // Routing GPS through _syncAllAnnotations made setGeoJsonSource fire on + // every tick, which triggered MapLibre's global symbol-collision recalc + // and made base-style POI labels flicker. Splitting the gates keeps + // _syncGpsSymbol cheap and lets _syncAllAnnotations idle when nothing + // marker-related has changed. + int _lastGpsSyncVersion = -1; + bool _gpsSyncInFlight = false; + + // Tile load failure detection — shows a banner if map tiles haven't loaded + // within a timeout after style load. Cleared when onMapIdle fires. + // Only surfaces the banner after 3 consecutive timeouts to avoid confusing + // users with transient failures. + int _consecutiveTileLoadFailures = 0; + static const _tileLoadFailureThreshold = 3; + + /// Tracks the last-applied mapTilesEnabled value so we can detect changes + /// in _buildMap and call setOffline() without a full style reload. + bool? _lastMapTilesEnabled; + Timer? _tileLoadTimeoutTimer; + static const _tileLoadTimeoutSeconds = 8; + + // Re-entrance guard for _onStyleLoaded. The iOS plugin can fire + // onStyleLoadedCallback multiple times during a single style switch, + // which causes the sync logic to race against itself. This flag bails + // any nested call. + bool _styleLoadInProgress = false; + + // True only after _setupRepeaterClusterLayers has finished creating the + // cluster GeoJSON source AND all 3 layers. Set to false at the start of + // each style load. Used as an additional guard for build()-triggered post- + // frame syncs so they don't race ahead of source creation and try to call + // setGeoJsonSource on a source that doesn't exist yet (which produces the + // "Failed to update repeater source: sourceNotFound" error at startup). + bool _clusterLayersReady = false; + + // True while the focus-lines source + 1-2 line layers are installed in the + // current style. Lets _updateFocusLines short-circuit when there's nothing + // to remove and nothing to add — touching MapLibre's layer stack (even with + // no-op removes that hit try/catch) crosses the platform channel and can + // nudge the symbol-collision pass. + bool _focusLinesInstalled = false; + + // Community coverage connection lines/cells (tap a tile -> fan out to heard + // repeaters; tap a repeater -> its coverage cells + lines). Install-once empty + // layers, updated in place via setGeoJsonSource. Separate from the focus-mode + // lines (which key off _focusedPingLocation and are rebuilt by + // _syncAllAnnotations) so the two features never wipe each other. + bool _coverageLinesInstalled = false; + bool _coverageCellsInstalled = false; + // Set while a tapped cell's fan-out is shown: the repeater ids that heard the + // cell's pings. Non-null -> _buildRepeaterFeatureCollection hides every + // repeater NOT in the set (web fades-but-keeps; the app hides for consistency + // with focus/isolation). Built from the capped endpoints. Null = inactive. + Set? _coverageHeardRepeaterIds; + // Distance-label pills for the cell fan-out lines (own tracking, mirrors the + // focus-mode distance labels but keyed by endpoint lat/lon at 6dp). + final Map _coverageDistanceLabelSymbols = {}; + final Set _registeredCoverageLabelImages = {}; + final Map _registeredCoverageLabelImageSizes = {}; + + // Native annotation tracking — populated by sync methods. + // Maps from app-state IDs to MapLibre Symbol/Line objects so we can diff + // (add new, update existing, remove deleted) on each data version change. + // NOTE: repeaters do NOT use the annotation manager — they live in a custom + // cluster-enabled GeoJSON source so MapLibre can group nearby markers into + // count bubbles at low zoom. See _setupRepeaterClusterLayers(). + final Map _coverageSymbols = {}; // key: "{type}_{ts.ms}" + // Last-applied visual signature (iconImage|iconSize) per coverage symbol. + // A symbol's geometry is baked into its key, so only icon image/size can + // change after creation (TX red→green on echo, focus enlarge). We skip the + // native updateSymbol round-trip when the signature is unchanged — without + // this, every sync re-pushed ALL accumulated symbols (O(n)/event), which + // made marker/ping display lag grow with session length. See _syncCoverageSymbols. + final Map _coverageSymbolSig = {}; + // Per-key render order: a monotonic counter assigned on first sight, so a + // newer symbol always sorts above an older one. Replaces a timestamp-derived + // sort key that overflowed float32 (the native symbol-sort-key) and quantized + // recent pings into ~4s buckets, stacking simultaneous RX unpredictably. + final Map _coverageZIndex = {}; + int _coverageZCounter = 0; + // GPS puck lives in its OWN dedicated GeoJSON source + symbol layer rendered + // ABOVE every other layer (coverage fills/pins, repeaters), so it is always on + // top by LAYER ORDER — not by competing for a symbol-sort-key inside the shared + // annotation source. Keeping it out of that shared source also removes the + // one-frame blink where a freshly-added coverage pin painted over the puck + // during the source's full re-layout (the old `_gpsZIndex` sort-key approach). + // Installed via _ensureGpsPuckLayer (re-installed on style reload, see + // _onStyleLoaded) and updated in place via setGeoJsonSource. + static const String _gpsPuckSourceId = 'gps-puck-source'; + static const String _gpsPuckLayerId = 'gps-puck-layer'; + bool _gpsPuckLayerInstalled = false; + final Map _distanceLabelSymbols = + {}; // key: focused repeater id + // Per focused-repeater metadata used by the collision-avoidance reflow: + // the image size (for hit-box overlap tests) and the repeater lat/lon (so + // we can slide the label along the ping→repeater line at a new parameter t). + final Map _distanceLabelImageSize = {}; + final Map _distanceLabelRepeaterPos = {}; + // Tracks distance-label image names we've registered via addImage, so the + // style-reload path can drop stale names from the map's image cache if ever + // needed. Right now we just re-addImage on each sync (idempotent). + final Set _registeredDistanceLabelImages = {}; + final Map _registeredDistanceLabelImageSizes = {}; + // Detailed-mode baked repeater chip image names already registered via + // addImage (deduped by "repchip_{status}_{hop}_{hex}"). Lazily grown by + // _ensureRepeaterChipImages; cleared on style reload (native drops images). + final Set _registeredChipImages = {}; + + // When true, _syncAllAnnotations skips _updateFocusLines and + // _syncDistanceLabels so the 500ms zoom-to-fit animation runs without + // contention from heavy native platform calls. The deferred work runs + // after the animation settles via _activatePingFocus's delayed callback. + bool _focusSyncDeferred = false; + + // Repeater cluster source/layer IDs (custom GeoJSON layer with cluster: true) + static const _repeaterSourceId = 'repeaters-source'; + static const _repeaterIndividualLayerId = 'repeaters-individual'; + static const _repeaterClusterBubbleLayerId = 'repeaters-cluster-bubble'; + static const _repeaterClusterCountLayerId = 'repeaters-cluster-count'; + + // Spiderfy source/layer IDs — non-clustered shadow source rendering spread + // markers + leader lines for stacked repeaters that won't separate by zoom. + static const _spiderSourceId = 'spider-source'; + static const _spiderLineLayerId = 'spider-leader-lines'; + static const _spiderSymbolLayerId = 'spider-symbols'; + // Matches the main source's clusterRadius (50px). Reused by the spiderfy + // group-detection logic: pairs of repeaters within `clusterRadius × m/px + // at the user's max zoom` of each other will visually overlap even when + // fully zoomed in, so they're the candidates that won't be separated by + // additional zoom and need to be spread apart instead. + static const double _clusterRadiusPx = 50; + static const double _spiderInnerRadiusPx = 44; + static const double _spiderOuterRadiusPx = 80; + static const double _leaderLineEndShortenPx = 8; + // Camera-zoom delta past which an open spider must collapse (positions are + // pixel-radius derived and become wrong if the user zooms far enough). + static const double _spiderCollapseZoomDelta = 0.25; + + // Regional boundary (from /border API — always visible) + static const _regionBorderSourceId = 'region-border-source'; + static const _regionBorderLineLayerId = 'region-border-line'; + static const _regionBorderLabelLayerId = 'region-border-label'; + int _lastBordersSignature = -1; + + // Tracks which marker style preference the coverage images are currently + // registered for. When the user changes their preference, we re-register. + String? _registeredCoverageStyle; + + // True after _registerMapImages() finishes — gates symbol creation. + bool _imagesRegistered = false; + + // Last bearing seen by camera listener (for non-rotating GPS counter-rotation) + double _lastBearing = 0; + + // Spiderfy state — when non-null, a stack of stacked repeaters has been + // fanned out around _spiderCenter into the shadow `spider-source`. + // Lifecycle: set by _spiderfy(), cleared by _collapseSpider(). + LatLng? _spiderCenter; + List _spiderRepeaters = const []; + // Captured on _spiderfy() so _onCameraChanged can detect a zoom delta past + // _spiderCollapseZoomDelta and collapse (positions become invalid). + double? _spiderOpenedAtZoom; + + // Repeater isolation — when non-null, ONLY this repeater is shown on the map; + // every other repeater is skipped in _buildRepeaterFeatureCollection (web + // parity: clicking a repeater hides the rest). Set when a single repeater's + // detail sheet opens, cleared when it closes/minimizes or on empty-map tap. + String? _isolatedRepeaterId; // Default center (Ottawa) static const LatLng _defaultCenter = LatLng(45.4215, -75.6972); static const double _defaultZoom = 15.0; // Closer zoom for driving + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _appLifecycleState = + WidgetsBinding.instance.lifecycleState ?? AppLifecycleState.resumed; + // Coverage-patch updates are driven by a DIRECT provider listener, not the + // build() watchers: when the device sits still nothing else rebuilds this + // widget, and a patch that waits for the next rebuild only appears after + // the user happens to touch the map. + _patchProviderRef = context.read(); + _patchProviderRef!.addListener(_onCoveragePatchNotify); + // GPS position drives camera-follow / heading / puck via a DIRECT listener + // (not build()): the camera follows every tick WITHOUT rebuilding the map, + // which would relayout the iOS platform view (~28 ms/tick). See + // _handleGpsPosition. GPS notifies no longer bump mapRevision, so the map's + // Selector doesn't rebuild on position. + _patchProviderRef!.addListener(_onPositionNotify); + // Coverage overlay opacity is UI-only state that must not bump mapRevision + // (a rebuild per slider step would relayout the platform view), so it needs + // a direct listener too. See _onCoverageOpacityNotify. + _patchProviderRef!.addListener(_onCoverageOpacityNotify); + } + + AppStateProvider? _patchProviderRef; + + void _onCoveragePatchNotify() { + final appState = _patchProviderRef; + if (appState == null || !mounted) return; + if (appState.coveragePatchVersion == _lastAppliedPatchVersion) return; + if (!_isMapReady || !_styleLoaded) return; + _lastAppliedPatchVersion = appState.coveragePatchVersion; + _applyCoveragePatch(appState); + } + + /// Fires on every provider notify; drives real-time camera-follow + GPS puck + /// from the current position without rebuilding the map. Heavy work inside is + /// version-gated, so non-position notifies are a cheap no-op. + void _onPositionNotify() { + final appState = _patchProviderRef; + if (appState == null || !mounted) return; + _handleGpsPosition(appState); + } + + /// Fires on every provider notify; pushes a changed coverage overlay opacity + /// straight into the live fill layers via the controller, no rebuild. + /// + /// This CANNOT be a build() watcher. The map is isolated behind the memoized + /// mapRevision Selector (home_screen._buildMapSelector) and the opacity + /// preference is UI-only state that deliberately does not bump mapRevision, + /// so build() never re-runs when the slider moves. The value then only + /// reached MapLibre on the next full overlay rebuild (#434). + /// + /// Skipped while ping focus mode is active (focus forces opacity to 0 and + /// _dismissPingFocus restores the preference value directly) and while a + /// tapped cell or an isolated repeater dims the backdrop, or this would + /// un-dim it mid-sheet. + void _onCoverageOpacityNotify() { + final appState = _patchProviderRef; + if (appState == null || !mounted) return; + if (!_isMapReady || !_styleLoaded) return; + if (_focusedPingLocation != null) return; + if (_coverageDimmedForCell || _coverageDimmedForRepeater) return; + if (_coverageOpacityApplyInFlight) return; + final wanted = appState.preferences.coverageOverlayOpacity; + if (_lastAppliedCoverageOpacity == null || + _lastAppliedCoverageOpacity == wanted) { + return; + } + _coverageOpacityApplyInFlight = true; + _applyCoverageOverlayOpacity(wanted).whenComplete(() { + _coverageOpacityApplyInFlight = false; + if (!mounted) return; + // A slider step that landed mid-flight was dropped by the guard above. + // Re-check so the final value still lands when no further notify follows + // (e.g. sitting in Settings, not wardriving). Gated on the apply having + // actually succeeded: on failure _lastAppliedCoverageOpacity is unchanged + // and re-running would spin forever. + if (_lastAppliedCoverageOpacity == wanted) _onCoverageOpacityNotify(); + }); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + final wasBackground = _appLifecycleState != AppLifecycleState.resumed; + _appLifecycleState = state; + + if (state != AppLifecycleState.resumed) { + // Going to background — block camera animations immediately. + _cameraAnimationReady = false; + } else if (wasBackground && _isMapReady) { + // Resuming from background — GL surface needs a frame to restore + // before constrainCameraAndZoomToBounds can project without NaN. + _cameraAnimationReady = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _cameraAnimationReady = true; + _recoverCoverageOverlayIfNeeded(); + // Markers that arrived while backgrounded were skipped because the + // build() marker-sync is gated on _cameraAnimationReady (and left + // _lastMarkerDataVersion stale). Now that the gate is open, force a + // rebuild so build() detects the version diff and syncs the + // accumulated pins — otherwise they don't render until the next + // mapRevision bump (the next ping). + setState(() {}); + } + }); + } + } + + /// Re-add the coverage overlay if it should be visible but was lost during + /// a background/foreground transition. + void _recoverCoverageOverlayIfNeeded() { + if (_activeCoverageSourceId != null) return; + if (!_showMeshMapperOverlay) return; + final appState = context.read(); + if (!appState.preferences.mapTilesEnabled) return; + if (appState.zoneCode == null || appState.zoneCode!.isEmpty) return; + debugLog('[MAP] Recovering coverage overlay after app resume'); + _addCoverageOverlay(appState); + } + @override void dispose() { - _animationController?.dispose(); - _rotationAnimationController?.dispose(); + WidgetsBinding.instance.removeObserver(this); + _patchProviderRef?.removeListener(_onCoveragePatchNotify); + _patchProviderRef?.removeListener(_onPositionNotify); + _patchProviderRef?.removeListener(_onCoverageOpacityNotify); + _tileLoadTimeoutTimer?.cancel(); + final controller = _mapController; + if (controller != null) { + controller.removeListener(_onCameraChanged); + // Symbol/feature tap listeners are registered in _onMapCreated onto + // separate callback collections that ChangeNotifier.dispose() does NOT + // clear. Remove them explicitly so an in-flight tap that gets queued + // before the platform channel is torn down can't reach into a disposed + // State. try/catch swallows the edge case where _onMapCreated never ran. + try { + controller.onSymbolTapped.remove(_handleSymbolTap); + } catch (_) {} + try { + controller.onFeatureTapped.remove(_handleFeatureTap); + } catch (_) {} + } super.dispose(); } + /// Camera change listener — fires every frame during pan/zoom (because + /// trackCameraPosition: true is set on MapLibreMap). With native annotations, + /// the markers themselves don't need a per-frame rebuild — they're rendered + /// by the native map engine and stay in sync automatically. The only thing + /// we still need to do here is update the GPS marker's iconRotate when the + /// camera bearing changes, because for rotating styles (arrow/walk/chomper) + /// iconRotate = heading - bearing and the bearing animates continuously in + /// heading mode. Throttled by a small bearing delta to avoid spamming + /// updateSymbol. + void _onCameraChanged() { + if (!mounted || _mapController == null) return; + final pos = _mapController!.cameraPosition; + if (pos == null) return; + + // If a spider is open and the user has zoomed past the collapse-delta + // threshold, drop the spider — pixel-radius spread positions are now wrong + // for the new zoom. Pure pan never crosses this threshold (zoom doesn't + // change), so the spider follows pan naturally via its geo coordinates. + // Once `_spiderCenter` is null after collapse, this branch no-ops. + if (_spiderCenter != null && _spiderOpenedAtZoom != null) { + if ((pos.zoom - _spiderOpenedAtZoom!).abs() > _spiderCollapseZoomDelta) { + _collapseSpider(); + } + } + + if ((pos.bearing - _lastBearing).abs() < 0.5) return; + _lastBearing = pos.bearing; + _updateGpsSymbolRotation(); + } + @override void didUpdateWidget(MapWidget oldWidget) { super.didUpdateWidget(oldWidget); // When padding changes (panel opened/closed/minimized/orientation change), re-center if auto-following - if ((widget.bottomPaddingPixels != oldWidget.bottomPaddingPixels || - widget.rightPaddingPixels != oldWidget.rightPaddingPixels) && + final paddingChanged = + widget.bottomPaddingPixels != oldWidget.bottomPaddingPixels || + widget.rightPaddingPixels != oldWidget.rightPaddingPixels; + if (paddingChanged) { + debugLog('[MAP CENTER] didUpdateWidget padding change: ' + 'bottom ${oldWidget.bottomPaddingPixels}->${widget.bottomPaddingPixels} ' + 'right ${oldWidget.rightPaddingPixels}->${widget.rightPaddingPixels} ' + '_autoFollow=$_autoFollow _isMapReady=$_isMapReady ' + '_lastGpsPosition=${_lastGpsPosition != null}'); + } + if (paddingChanged && _autoFollow && _isMapReady && _lastGpsPosition != null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _autoFollow && _lastGpsPosition != null) { + final double targetBearing = + (!_alwaysNorth && _computedHeading != null) + ? _computedHeading! + : 0.0; + final double targetZoom = _autoFollowDesiredZoom ?? + _mapController?.cameraPosition?.zoom ?? + _defaultZoom; final adjustedPosition = _offsetPositionForPadding( _lastGpsPosition!, widget.bottomPaddingPixels, widget.rightPaddingPixels, + targetZoom, + targetBearing, + ); + final cam = _mapController?.cameraPosition; + debugLog('[MAP CENTER] re-center after padding change: ' + 'gps=(${_lastGpsPosition!.latitude.toStringAsFixed(6)},${_lastGpsPosition!.longitude.toStringAsFixed(6)}) ' + 'target=(${adjustedPosition.latitude.toStringAsFixed(6)},${adjustedPosition.longitude.toStringAsFixed(6)}) ' + 'deltaLat=${(adjustedPosition.latitude - _lastGpsPosition!.latitude).toStringAsFixed(6)} ' + 'deltaLon=${(adjustedPosition.longitude - _lastGpsPosition!.longitude).toStringAsFixed(6)} ' + 'zoom=${targetZoom.toStringAsFixed(2)} bearing=${targetBearing.toStringAsFixed(2)} ' + 'curZoom=${cam?.zoom.toStringAsFixed(2)} curBearing=${cam?.bearing.toStringAsFixed(2)} ' + 'alwaysNorth=$_alwaysNorth'); + _animateAutoFollowCamera( + target: adjustedPosition, + zoom: targetZoom, + bearing: targetBearing, ); - _animateToPosition(adjustedPosition); } }); } } - /// Smoothly animate the map to a new position - void _animateToPosition(LatLng target) { - if (!_isMapReady || !mounted) return; - - // Get current position - final currentCenter = _mapController.camera.center; - - // Skip if already at target (within small threshold) - final distance = const Distance().as(LengthUnit.Meter, currentCenter, target); - if (distance < 1) return; // Less than 1 meter, don't animate - - // Cancel any running animation - _animationController?.stop(); - _animationController?.dispose(); - - // Create new animation controller - // Duration based on distance - shorter for small movements, longer for big jumps - final duration = Duration(milliseconds: distance < 100 ? 200 : 300); - - _animationController = AnimationController( - duration: duration, - vsync: this, + /// Smoothly animate the map to a new position with zoom + void _animateToPositionWithZoom(LatLng target, double targetZoom) { + if (_mapController == null || + !_isMapReady || + !mounted || + !_canAnimateCamera) { + return; + } + // Guard against NaN/infinite/out-of-range coords — MapLibre's native LatLng + // ctor throws (uncaught → SIGABRT) on invalid input. See isValidLatLng. + if (!isValidLatLng(target.latitude, target.longitude)) { + debugWarn('[MAP] Skipping camera move to invalid target ' + '(${target.latitude}, ${target.longitude})'); + return; + } + _mapController!.animateCamera( + CameraUpdate.newLatLngZoom(target, targetZoom), + duration: const Duration(milliseconds: 500), ); + } - _animation = CurvedAnimation( - parent: _animationController!, - curve: Curves.easeOutCubic, // Smooth deceleration + /// Atomic auto-follow camera update: animates target, zoom, and bearing + /// together in a single animateCamera call. + /// + /// Using separate animateCamera calls for position and rotation races — + /// the second call cancels the first, so each GPS tick in heading mode + /// lost either the pan or the rotation. Bundling everything into one + /// newCameraPosition update avoids the race entirely and also keeps the + /// initial zoom animation from being cancelled by the first auto-follow + /// tick. + void _animateAutoFollowCamera({ + required LatLng target, + required double zoom, + required double bearing, + int durationMs = 300, + }) { + if (_mapController == null || + !_isMapReady || + !mounted || + !_canAnimateCamera) { + return; + } + // Guard against NaN/infinite/out-of-range coords — MapLibre's native LatLng + // ctor throws (uncaught → SIGABRT) on invalid input. See isValidLatLng. + if (!isValidLatLng(target.latitude, target.longitude)) { + debugWarn('[MAP] Skipping auto-follow camera move to invalid target ' + '(${target.latitude}, ${target.longitude})'); + return; + } + _mapController!.animateCamera( + CameraUpdate.newCameraPosition(CameraPosition( + target: target, + zoom: zoom, + bearing: bearing, + )), + duration: Duration(milliseconds: durationMs), ); - - _animationStartPosition = currentCenter; - _animationEndPosition = target; - - _animation!.addListener(() { - if (!mounted || _animationStartPosition == null || _animationEndPosition == null) return; - - // Interpolate between start and end positions - final t = _animation!.value; - final lat = _animationStartPosition!.latitude + - ((_animationEndPosition!.latitude - _animationStartPosition!.latitude) * t); - final lng = _animationStartPosition!.longitude + - ((_animationEndPosition!.longitude - _animationStartPosition!.longitude) * t); - - _mapController.move(LatLng(lat, lng), _mapController.camera.zoom); - }); - - _animationController!.forward(); } - /// Smoothly animate the map to a new position with zoom - void _animateToPositionWithZoom(LatLng target, double targetZoom) { - if (!_isMapReady || !mounted) return; - - // Get current position and zoom - final currentCenter = _mapController.camera.center; - final currentZoom = _mapController.camera.zoom; - - // Cancel any running animation - _animationController?.stop(); - _animationController?.dispose(); - - // Create new animation controller - const duration = Duration(milliseconds: 500); // Smooth zoom + pan + // =========================================================================== + // Shared focus-camera lifecycle (ping focus + tile/repeater coverage views) + // =========================================================================== + + /// Animate the camera to fit [points] on screen (north-up, with room for the + /// bottom sheet/pill). No-op with fewer than 2 valid points (nothing to frame). + void _fitCameraToPoints(List points) { + if (_mapController == null || + !_isMapReady || + !mounted || + !_canAnimateCamera) { + return; + } + final valid = + points.where((p) => isValidLatLng(p.latitude, p.longitude)).toList(); + if (valid.length < 2) return; + + double minLat = valid[0].latitude, maxLat = valid[0].latitude; + double minLon = valid[0].longitude, maxLon = valid[0].longitude; + for (final p in valid) { + 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; + } - _animationController = AnimationController( - duration: duration, - vsync: this, + // Leave room for the bottom sheet/pill that accompanies focus views. + final bottomPad = MediaQuery.of(context).size.height * 0.4; + _animateFitBounds( + minLat: minLat, + maxLat: maxLat, + minLon: minLon, + maxLon: maxLon, + leftPad: 60, + topPad: 60, + rightPad: 60, + bottomPad: bottomPad, ); + } - _animation = CurvedAnimation( - parent: _animationController!, - curve: Curves.easeInOutCubic, // Smooth acceleration and deceleration - ); + /// Fit the camera to a lat/lon box, guarding the two inputs MapLibre will + /// choke on: a degenerate (zero-area) box and edge padding that exceeds the + /// map's rendered size. Either yields a non-finite zoom that propagates into + /// MapLibre's native `unproject` and aborts the app with an uncaught C++ + /// `LatLng` throw (SIGABRT) — the same failure mode as an invalid coordinate, + /// which a plain lat/lon validity check does not catch. A degenerate box falls + /// back to a plain center+zoom move; padding is clamped to the live map size. + void _animateFitBounds({ + required double minLat, + required double maxLat, + required double minLon, + required double maxLon, + double leftPad = 60, + double topPad = 60, + double rightPad = 60, + double bottomPad = 60, + }) { + if (_mapController == null || + !_isMapReady || + !mounted || + !_canAnimateCamera) { + return; + } + final centerLat = (minLat + maxLat) / 2; + final centerLon = (minLon + maxLon) / 2; + + // A single point (or coincident cluster) has no span to frame — center on it + // instead of asking MapLibre to fit a zero-area box. + if (isDegenerateBounds(minLat, maxLat, minLon, maxLon)) { + _animateToPositionWithZoom( + LatLng(centerLat, centerLon), 16.0 - _zoomEpsilon); + return; + } - _animationStartPosition = currentCenter; - _animationEndPosition = target; + // Belt-and-suspenders: never hand MapLibre a non-finite corner. + if (!isValidLatLng(minLat, minLon) || !isValidLatLng(maxLat, maxLon)) { + debugWarn('[MAP] Skipping fit-bounds with invalid corners ' + '($minLat,$minLon)-($maxLat,$maxLon)'); + return; + } - _animation!.addListener(() { - if (!mounted || _animationStartPosition == null || _animationEndPosition == null) return; + final size = context.size ?? MediaQuery.of(context).size; + final pad = clampFitPadding( + leftPad, topPad, rightPad, bottomPad, size.width, size.height); - // Interpolate between start and end positions - final t = _animation!.value; - final lat = _animationStartPosition!.latitude + - ((_animationEndPosition!.latitude - _animationStartPosition!.latitude) * t); - final lng = _animationStartPosition!.longitude + - ((_animationEndPosition!.longitude - _animationStartPosition!.longitude) * t); + _mapController!.animateCamera( + CameraUpdate.newLatLngBounds( + LatLngBounds( + southwest: LatLng(minLat, minLon), + northeast: LatLng(maxLat, maxLon), + ), + left: pad.left, + top: pad.top, + right: pad.right, + bottom: pad.bottom, + ), + duration: const Duration(milliseconds: 500), + ); + } - // Interpolate zoom - final zoom = currentZoom + ((targetZoom - currentZoom) * t); + /// Engage the shared focus camera: save the user's current camera / auto-follow + /// / rotation ONCE (so a switch between focus views keeps the original + /// snapshot), then lock north-up and stop following. Idempotent — a no-op when + /// a focus camera is already active. Camera/follow/rotation ONLY: it does NOT + /// touch the coverage overlay opacity or [isFocusModeActive] (each mode owns + /// those). + void _enterFocusCamera() { + if (_cameraFocusActive) return; + _cameraFocusActive = true; + final pos = _mapController?.cameraPosition; + _preFocusCenter = pos?.target; + _preFocusZoom = pos?.zoom; + _wasAutoFollowBeforeFocus = _autoFollow; + _wasRotatingBeforeFocus = !_alwaysNorth; + if (_autoFollow) _autoFollow = false; + if (!_alwaysNorth) { + _alwaysNorth = true; + // Snap rotation to north instantly so the zoom-to-fit view is stable. + if (_isMapReady && _mapController != null && _canAnimateCamera) { + _mapController!.animateCamera( + CameraUpdate.bearingTo(0), + duration: const Duration(milliseconds: 1), + ); + } + } + } - _mapController.move(LatLng(lat, lng), zoom); - }); + /// Disengage the shared focus camera: animate back to the saved center/zoom and + /// restore auto-follow / rotation (after the zoom-back settles). Idempotent. + void _exitFocusCamera() { + if (!_cameraFocusActive) return; + _cameraFocusActive = false; + final center = _preFocusCenter; + final zoom = _preFocusZoom; + final restoreFollow = _wasAutoFollowBeforeFocus && !_autoFollow; + final restoreRot = _wasRotatingBeforeFocus && _alwaysNorth; + if (center != null && zoom != null) { + _animateToPositionWithZoom(center, zoom); + // Defer follow/rotation restore so they don't clobber the zoom-back + // animation mid-flight (both share the animation controller). + if (restoreFollow || restoreRot) { + Future.delayed(const Duration(milliseconds: 550), () { + if (!mounted) return; + setState(() { + if (restoreFollow) _autoFollow = true; + if (restoreRot) _alwaysNorth = false; + }); + }); + } + } else { + setState(() { + if (restoreFollow) _autoFollow = true; + if (restoreRot) _alwaysNorth = false; + }); + } + } - _animationController!.forward(); + /// True while any focus view (ping focus, tile coverage, repeater coverage) is + /// open. Drives [_exitFocusCameraIfDone] so a view switch (which clears the old + /// view AFTER the new one is claimed) keeps the camera engaged. + bool get _anyFocusViewActive => + _focusedPingLocation != null || + _cellPopupActive || + _isolatedRepeaterId != null; + + /// Restore the shared focus camera only when no focus view remains open. + void _exitFocusCameraIfDone() { + if (!_anyFocusViewActive) _exitFocusCamera(); } /// Smoothly animate the map rotation to match heading + /// MapLibre bearing is clockwise from north (same as GPS heading) void _animateToRotation(double targetHeading) { - if (!_isMapReady || !mounted || _alwaysNorth) return; - - // Get current rotation (in degrees) - final currentRotation = _mapController.camera.rotation; - - // Normalize target heading to -180 to 180 range for smooth rotation - // Map heading is counter-clockwise from north, GPS heading is clockwise - // So we need to negate it: -targetHeading - double targetRotation = -targetHeading; - - // Normalize angles to -180 to 180 range - while (targetRotation > 180) { - targetRotation -= 360; - } - while (targetRotation < -180) { - targetRotation += 360; + if (_mapController == null || + !_isMapReady || + !mounted || + _alwaysNorth || + !_canAnimateCamera) { + return; } + final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; + // Calculate shortest rotation path - double delta = targetRotation - currentRotation; + double delta = targetHeading - currentBearing; while (delta > 180) { delta -= 360; } @@ -335,230 +1330,449 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Skip if rotation change is very small (less than 2 degrees) if (delta.abs() < 2) return; - // Cancel any running rotation animation - _rotationAnimationController?.stop(); - _rotationAnimationController?.dispose(); - - // Create new rotation animation controller - // Faster rotation for small changes, slower for large changes - final duration = Duration(milliseconds: delta.abs() < 45 ? 300 : 500); - - _rotationAnimationController = AnimationController( - duration: duration, - vsync: this, - ); - - _rotationAnimation = CurvedAnimation( - parent: _rotationAnimationController!, - curve: Curves.easeInOutCubic, // Smooth acceleration and deceleration + _mapController!.animateCamera( + CameraUpdate.bearingTo(targetHeading), + duration: Duration(milliseconds: delta.abs() < 45 ? 300 : 500), ); + } - _rotationStartAngle = currentRotation; - _rotationEndAngle = currentRotation + delta; - - _rotationAnimation!.addListener(() { - if (!mounted || _rotationStartAngle == null || _rotationEndAngle == null) return; - - // Interpolate between start and end angles - final t = _rotationAnimation!.value; - final rotation = _rotationStartAngle! + - ((_rotationEndAngle! - _rotationStartAngle!) * t); + /// Produce a reliable heading in degrees (0..360) from successive GPS fixes. + /// + /// Prefers `Position.heading` when the device is moving fast enough for the + /// hardware bearing to be trustworthy; otherwise derives the bearing from + /// the delta between the last anchor fix and the current one. Returns the + /// last known-good value (possibly null) when we don't have enough motion + /// yet. This exists because geolocator reports heading=0 (Android) or + /// -1 (iOS) at rest and during slow/stop-and-go movement, which would + /// otherwise leave the arrow/walk marker stuck pointing north. + double? _computeHeading(Position p) { + final here = LatLng(p.latitude, p.longitude); + + // Fast path: trust the GPS chip when it's actually moving. + // geolocator reports speed in m/s. 1 m/s ≈ 3.6 km/h — slower than that, + // the hardware bearing is either stale or not computed. + final gpsHeading = p.heading; + if (p.speed >= 1.0 && gpsHeading >= 0 && gpsHeading <= 360) { + _bearingAnchor = here; + _computedHeading = gpsHeading; + return _computedHeading; + } - _mapController.rotate(rotation); - }); + // Slow/stationary path: compute our own bearing once we have enough travel. + if (_bearingAnchor == null) { + _bearingAnchor = here; + } else { + final moved = Geolocator.distanceBetween( + _bearingAnchor!.latitude, + _bearingAnchor!.longitude, + here.latitude, + here.longitude, + ); + if (moved >= 5.0) { + final bearing = Geolocator.bearingBetween( + _bearingAnchor!.latitude, + _bearingAnchor!.longitude, + here.latitude, + here.longitude, + ); + // bearingBetween returns -180..180; normalize to 0..360. + _computedHeading = (bearing + 360) % 360; + _bearingAnchor = here; + } + } - _rotationAnimationController!.forward(); + return _computedHeading; // may be null until first meaningful motion } - /// Offset a lat/lon position by screen pixels (to account for UI overlays) - /// Shifts the map center to keep the GPS marker centered in the visible map area - /// - bottomPadding: shifts center down (portrait mode with bottom panel) - /// - rightPadding: shifts center left (landscape mode with side panel) - LatLng _offsetPositionForPadding(LatLng position, double bottomPadding, [double rightPadding = 0, double? atZoom]) { - if (!_isMapReady) return position; + /// Offset a lat/lon position by screen pixels (to account for UI overlays). + /// Shifts the camera target so the GPS marker sits in the visible (unpadded) + /// part of the map: + /// - bottomPadding > 0: camera shifts "screen-down" so marker appears toward + /// the top half (portrait with bottom panel open). + /// - rightPadding > 0: camera shifts "screen-right" so marker appears toward + /// the left half (landscape with side panel open on the right). + /// + /// [atZoom] and [atBearing] override the current camera values. Callers that + /// are *about* to animate the camera to a new zoom/bearing must pass the + /// target values — otherwise the offset gets computed at an interpolated + /// mid-animation value and the marker settles off-center. + LatLng _offsetPositionForPadding( + LatLng position, + double bottomPadding, [ + double rightPadding = 0, + double? atZoom, + double? atBearing, + ]) { + if (_mapController == null || !_isMapReady) return position; if (bottomPadding <= 0 && rightPadding <= 0) return position; - // Get meters per pixel at current zoom (or at a specific zoom if provided) - // Approx: 40075km / (256 * 2^zoom) at equator, adjusted by cos(lat) - final zoom = atZoom ?? _mapController.camera.zoom; - final metersPerPixel = 40075000 / (256 * math.pow(2, zoom)) * + // MapLibre's internal projection uses 512-logical-px tile units (see + // MapLibre style spec — vector and raster sources are reprojected onto a + // 512px grid regardless of source tile size). The previous formula here + // assumed 256-px tiles, which made every offset 2× too large and pushed + // the GPS marker far above the visible-area centre. + final zoom = atZoom ?? _mapController!.cameraPosition?.zoom ?? _defaultZoom; + final metersPerPixel = 40075000 / + (512 * math.pow(2, zoom)) * math.cos(position.latitude * math.pi / 180); - double latOffset = 0; - double lonOffset = 0; - - // Bottom padding: shift center south (map moves up, marker appears centered) - if (bottomPadding > 0) { - final meterOffset = (bottomPadding / 2) * metersPerPixel; - latOffset = -(meterOffset / 111000); // ~111km per degree latitude - } - - // Right padding: shift center west (map moves right, marker appears centered) - if (rightPadding > 0) { - final meterOffset = (rightPadding / 2) * metersPerPixel; - // Longitude degrees per meter varies with latitude - lonOffset = -(meterOffset / (111000 * math.cos(position.latitude * math.pi / 180))); - } - - // When the map is rotated (heading mode), geographic "south" no longer maps - // to "screen down". Rotate the offset vector by the camera rotation so the - // shift always points in the correct screen direction. - final rotationDeg = _mapController.camera.rotation; - if (rotationDeg.abs() > 0.1) { - final rotationRad = -rotationDeg * math.pi / 180; - final cosR = math.cos(rotationRad); - final sinR = math.sin(rotationRad); - final rotatedLat = latOffset * cosR - lonOffset * sinR; - final rotatedLon = latOffset * sinR + lonOffset * cosR; - latOffset = rotatedLat; - lonOffset = rotatedLon; + // One-time sanity check: log MapLibre's authoritative m/px and compare + // to our formula. If they differ, the tile-size assumption is wrong. + if (!_loggedMpxSanityCheck) { + _loggedMpxSanityCheck = true; + _mapController! + .getMetersPerPixelAtLatitude(position.latitude) + .then((mapLibreMpx) { + debugLog( + '[MAP CENTER] m/px sanity: formula=${metersPerPixel.toStringAsFixed(4)} ' + 'maplibre=${mapLibreMpx.toStringAsFixed(4)} ' + 'ratio=${(metersPerPixel / mapLibreMpx).toStringAsFixed(3)} ' + '(zoom=${zoom.toStringAsFixed(2)} lat=${position.latitude.toStringAsFixed(4)})'); + }).catchError((e) { + debugLog('[MAP CENTER] m/px sanity check failed: $e'); + }); } - return LatLng(position.latitude + latOffset, position.longitude + lonOffset); + // Compute the desired camera shift in WORLD METERS along the + // (north, east) axes. Working in metres up front avoids the previous + // unit-mixing bug, where lat-degrees and lon-degrees were rotated as if + // they were the same unit (1° lat ≠ 1° lon away from the equator). + // + // We want the marker (at `position`) to appear shifted "screen-up" by + // `bottomPadding/2` and "screen-left" by `rightPadding/2` relative to + // screen centre, so the camera target itself shifts in the opposite + // direction (screen-down + screen-right). + final bearingDeg = + atBearing ?? _mapController!.cameraPosition?.bearing ?? 0; + final bearingRad = bearingDeg * math.pi / 180; + final cosB = math.cos(bearingRad); + final sinB = math.sin(bearingRad); + + // At bearing β (clockwise from north), world unit-vectors of the + // screen axes are: + // screen-down = (-cosβ, -sinβ) in (north, east) + // screen-right = (-sinβ, cosβ) + final downMetres = bottomPadding / 2 * metersPerPixel; + final rightMetres = rightPadding / 2 * metersPerPixel; + final northShift = downMetres * -cosB + rightMetres * -sinB; + final eastShift = downMetres * -sinB + rightMetres * cosB; + + // Convert metres → degrees. 1° latitude ≈ 111 km everywhere; 1° longitude + // shrinks by cos(latitude). + final latOffset = northShift / 111000; + final lonOffset = + eastShift / (111000 * math.cos(position.latitude * math.pi / 180)); + + return LatLng( + position.latitude + latOffset, position.longitude + lonOffset); } - @override - Widget build(BuildContext context) { - final appState = context.watch(); - - // Load saved map toggle preferences once, after Hive has finished loading - if (!_prefsApplied && appState.preferencesLoaded) { - _prefsApplied = true; - _autoFollow = appState.preferences.mapAutoFollow; - _alwaysNorth = appState.preferences.mapAlwaysNorth; - _rotationLocked = appState.preferences.mapRotationLocked; - } - - // Determine map center - prefer current GPS, fallback to last known, then Ottawa + /// Drives camera-follow, derived heading, one-time zooms, and the GPS-marker + /// puck from the current GPS position. Invoked on EVERY GPS tick by a direct + /// provider listener (`_onPositionNotify`) so the camera follows in real time + /// WITHOUT rebuilding `MapWidget` — a rebuild relayouts the iOS platform view + /// (~28 ms) every tick, which was the dominant wardriving CPU/heat cost. The + /// camera move itself is the same native controller call (`animateCamera`) as + /// before; only its trigger moved out of `build()`. Also called from `build()` + /// as an idempotent safety net (all heavy work is version-gated). + void _handleGpsPosition(AppStateProvider appState) { + // Map center — prefer current GPS, fallback to last known. Only adopt a + // source position when its coords are valid; otherwise stay on the safe + // default center (invalid coords abort the app in MapLibre's LatLng ctor). LatLng center = _defaultCenter; - if (appState.currentPosition != null) { - center = LatLng( - appState.currentPosition!.latitude, - appState.currentPosition!.longitude, - ); - } else if (appState.lastKnownPosition != null) { - center = LatLng( - appState.lastKnownPosition!.lat, - appState.lastKnownPosition!.lon, - ); + final pos = appState.currentPosition; + final lastKnown = appState.lastKnownPosition; + if (pos != null && isValidLatLng(pos.latitude, pos.longitude)) { + center = LatLng(pos.latitude, pos.longitude); + } else if (lastKnown != null && + isValidLatLng(lastKnown.lat, lastKnown.lon)) { + center = LatLng(lastKnown.lat, lastKnown.lon); } - // One-time zoom to last known position when GPS is not yet available - // This runs before GPS locks, so user sees their previous location instead of Ottawa + // One-time zoom to last known position when GPS is not yet available. if (appState.currentPosition == null && - appState.lastKnownPosition != null && + lastKnown != null && + isValidLatLng(lastKnown.lat, lastKnown.lon) && !_hasZoomedToLastKnown && - _isMapReady) { + _isMapReady && + _canAnimateCamera) { _hasZoomedToLastKnown = true; - final lastKnownCenter = LatLng( - appState.lastKnownPosition!.lat, - appState.lastKnownPosition!.lon, - ); + final lastKnownCenter = LatLng(lastKnown.lat, lastKnown.lon); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { - _animateToPositionWithZoom(lastKnownCenter, 15.0); + _animateToPositionWithZoom(lastKnownCenter, 15.0 - _zoomEpsilon); debugLog('[MAP] Initial zoom to last known position'); } }); } if (appState.currentPosition != null) { - // One-time initial zoom to GPS when we first get a position - // This happens even with auto-follow disabled so user sees their location - // Don't apply panel offset - center directly on GPS so pin is in middle of screen - if (!_hasInitialZoomed && _isMapReady) { + // Recompute derived heading (more reliable than position.heading at low + // speeds); _computedHeading is updated as a side effect. + _computeHeading(appState.currentPosition!); + + // One-time initial zoom to GPS (even with auto-follow off, centered on GPS). + if (!_hasInitialZoomed && _isMapReady && _canAnimateCamera) { _hasInitialZoomed = true; final initialPosition = center; _lastGpsPosition = initialPosition; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { if (_autoFollow) { - // Auto-follow is on and panel may be open — apply panel offset so - // the marker appears centered in the visible map area. - final adjustedPosition = _offsetPositionForPadding(initialPosition, widget.bottomPaddingPixels, widget.rightPaddingPixels, 16.0); - _animateToPositionWithZoom(adjustedPosition, 16.0); - debugLog('[MAP] Initial zoom to GPS position (with panel offset)'); + final adjustedPosition = _offsetPositionForPadding( + initialPosition, + widget.bottomPaddingPixels, + widget.rightPaddingPixels, + 16.0 - _zoomEpsilon); + _animateToPositionWithZoom(adjustedPosition, 16.0 - _zoomEpsilon); + debugLog( + '[MAP] Initial zoom to GPS position (with panel offset)'); } else { - _animateToPositionWithZoom(initialPosition, 16.0); + _animateToPositionWithZoom(initialPosition, 16.0 - _zoomEpsilon); debugLog('[MAP] Initial zoom to GPS position'); } } }); } - // Auto-follow GPS position when enabled - use smooth animation - if (_autoFollow && _isMapReady) { + // Auto-follow: bundle pan, zoom, and bearing into one animateCamera call. + // Gate on _canAnimateCamera (not just _cameraAnimationReady) so the first + // follow tick is also held until the map has rendered once — same backstop + // as the initial zoom against the degenerate-viewport flyTo abort. + if (_autoFollow && _isMapReady && _canAnimateCamera) { final newPosition = center; - // Only animate if position has actually changed if (_lastGpsPosition == null || _lastGpsPosition!.latitude != newPosition.latitude || _lastGpsPosition!.longitude != newPosition.longitude) { _lastGpsPosition = newPosition; - // Use post frame callback to avoid build-during-build issues + final double targetBearing = + (!_alwaysNorth && _computedHeading != null) + ? _computedHeading! + : 0.0; + final double targetZoom = _autoFollowDesiredZoom ?? + _mapController?.cameraPosition?.zoom ?? + _defaultZoom; + if (!_alwaysNorth && _computedHeading != null) { + _lastHeading = _computedHeading; + } WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _autoFollow) { - // Apply offset for bottom padding when control panel is open - final adjustedPosition = _offsetPositionForPadding(newPosition, widget.bottomPaddingPixels, widget.rightPaddingPixels); - _animateToPosition(adjustedPosition); // Smooth animation instead of jump + final adjustedPosition = _offsetPositionForPadding( + newPosition, + widget.bottomPaddingPixels, + widget.rightPaddingPixels, + targetZoom, + targetBearing, + ); + _animateAutoFollowCamera( + target: adjustedPosition, + zoom: targetZoom, + bearing: targetBearing, + ); } }); } } - // Handle map rotation based on heading (when not in Always North mode) - if (!_alwaysNorth && _isMapReady) { - final heading = appState.currentPosition!.heading; + // Heading-based rotation when NOT auto-following. + if (!_autoFollow && + !_alwaysNorth && + _isMapReady && + _computedHeading != null) { + final heading = _computedHeading!; if (_lastHeading == null) { - // First heading after startup — store without rotating so the - // initial zoom animation can settle at rotation 0 (where the - // panel offset was computed). Heading mode will begin rotating - // on the next GPS update when heading changes. _lastHeading = heading; - debugLog('[MAP] First heading after startup (${heading.toStringAsFixed(1)}°) — stored without rotating'); + debugLog( + '[MAP] First heading after startup (${heading.toStringAsFixed(1)}°) — stored without rotating'); } else if ((heading - _lastHeading!).abs() > 2) { _lastHeading = heading; - // Use post frame callback to avoid build-during-build issues WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && !_alwaysNorth) { + if (mounted && !_alwaysNorth && !_autoFollow) { _animateToRotation(heading); } }); } } + } else { + // GPS lock lost — clear bearing state so reacquisition starts fresh. + _bearingAnchor = null; + _computedHeading = null; + } + + // GPS marker puck — cheap updateSymbol, gated by gpsVersion (position + + // heading + style). Real-time every tick; does NOT go through the heavy + // _syncAllAnnotations pipeline. + if (_isMapReady && + _styleLoaded && + _imagesRegistered && + _cameraAnimationReady) { + final gpsVersion = Object.hash( + appState.currentPosition?.latitude, + appState.currentPosition?.longitude, + _computedHeading, + appState.preferences.gpsMarkerStyle, + ); + if (gpsVersion != _lastGpsSyncVersion) { + // NOT recorded here: _syncGpsSymbol records the version of what it + // actually pushed. Recording eagerly marked dropped updates (in-flight + // skip, failed install) as applied, and with GpsService's + // distanceFilter there is no periodic tick to paper over the gap, so + // the puck stayed wherever the drop left it until the next real + // movement (#482). + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + if (_gpsSyncInFlight) return; + _gpsSyncInFlight = true; + try { + await _syncGpsSymbol(appState); + } catch (e) { + debugError('[MAP] _syncGpsSymbol failed: $e'); + } finally { + _gpsSyncInFlight = false; + } + }); + } + } + } + + @override + Widget build(BuildContext context) { + // NOTE: read (not watch). This widget is rebuilt by a Selector in + // home_screen.dart keyed on AppStateProvider.mapRevision (+ layout inputs), + // so it must NOT subscribe to the whole provider — that was the overheating + // root cause (the map rebuilt on every notifyListeners). build() runs + // whenever the Selector rebuilds it, at which point read gives fresh state. + final appState = context.read(); + + // Load saved map toggle preferences once, after Hive has finished loading + if (!_prefsApplied && appState.preferencesLoaded) { + _prefsApplied = true; + _autoFollow = appState.preferences.mapAutoFollow; + _alwaysNorth = appState.preferences.mapAlwaysNorth; + _rotationLocked = appState.preferences.mapRotationLocked; + } + + // Determine map center - prefer current GPS, fallback to last known, then + // Ottawa. Only adopt a source position when its coords are valid — this + // feeds initialCameraPosition, and an invalid LatLng aborts the app in + // MapLibre's native ctor (the launch/resume crash). + LatLng center = _defaultCenter; + final centerPos = appState.currentPosition; + final centerLastKnown = appState.lastKnownPosition; + if (centerPos != null && + isValidLatLng(centerPos.latitude, centerPos.longitude)) { + center = LatLng(centerPos.latitude, centerPos.longitude); + } else if (centerLastKnown != null && + isValidLatLng(centerLastKnown.lat, centerLastKnown.lon)) { + center = LatLng(centerLastKnown.lat, centerLastKnown.lon); } + // Camera-follow / derived heading / one-time zooms / GPS-marker puck now + // run in _handleGpsPosition, invoked every GPS tick by _onPositionNotify + // (real-time follow WITHOUT rebuilding the map / relayouting the platform + // view). Called here too as an idempotent safety net — version-gated. + _handleGpsPosition(appState); + // Handle navigation trigger from log screen or graph // Reset map state and navigate to the target location - if (_isMapReady && appState.mapNavigationTrigger != _lastNavigationTrigger) { + if (_isMapReady && + appState.mapNavigationTrigger != _lastNavigationTrigger) { _lastNavigationTrigger = appState.mapNavigationTrigger; final target = appState.mapNavigationTarget; if (target != null) { // Reset map controls to default state - _autoFollow = false; // Disable center on GPS - _alwaysNorth = true; // Set to north-up mode - _rotationLocked = false; // Unlock rotation - _lastHeading = null; // Reset heading tracking + _autoFollow = false; // Disable center on GPS + _autoFollowDesiredZoom = null; + _alwaysNorth = true; // Set to north-up mode + _rotationLocked = false; // Unlock rotation + _lastHeading = null; // Reset heading tracking + _bearingAnchor = null; // Reset derived-heading anchor + _computedHeading = null; // Navigate to the coordinates with close zoom (18 = street level view) // Center directly on target without offset - we want the pin in the middle WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { + if (mounted && _mapController != null) { final targetPosition = LatLng(target.lat, target.lon); // Rotate map back to north (0 degrees) first - final currentRotation = _mapController.camera.rotation; - if (currentRotation.abs() > 2) { - _mapController.rotate(0); + final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; + if (currentBearing.abs() > 2 && _canAnimateCamera) { + _mapController!.animateCamera(CameraUpdate.bearingTo(0)); } // Animate to the exact target position (no offset) - _animateToPositionWithZoom(targetPosition, 18.0); + _animateToPositionWithZoom(targetPosition, 18.0 - _zoomEpsilon); + } + }); + } + } + + // Handle history session view — fit camera to session bounds on transition + if (_isMapReady && appState.viewingHistorySession && !_wasViewingHistory) { + _wasViewingHistory = true; + final markers = appState.historySessionMarkers; + if (markers != null && markers.isNotEmpty) { + _autoFollow = false; + _alwaysNorth = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _mapController == null) return; + _fitCameraToHistoryMarkers(markers); + }); + } + } else if (!appState.viewingHistorySession && _wasViewingHistory) { + _wasViewingHistory = false; + } + + // Sync native annotations whenever marker data changes (provider triggers + // a rebuild). The version hash detects changes to ping/repeater counts, + // GPS position, focus state, prefs, etc. Native annotations stay in sync + // with the camera automatically — we only need to push data updates. + // + // _clusterLayersReady is the critical guard here: it ensures the cluster + // GeoJSON source actually exists before any sync attempts to push data + // into it. Without this, a Provider data update arriving in the brief + // window between _registerMapImages and _setupRepeaterClusterLayers + // (inside _onStyleLoaded) would race ahead and call setGeoJsonSource on + // a not-yet-created source, throwing "sourceNotFound". + if (_isMapReady && + _styleLoaded && + _imagesRegistered && + _clusterLayersReady && + _cameraAnimationReady) { + final dataVersion = _computeMarkerDataVersion(appState); + if (dataVersion != _lastMarkerDataVersion) { + _lastMarkerDataVersion = dataVersion; + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + // Guard against concurrent build()-triggered syncs stepping on each + // other. _syncAllAnnotations awaits multiple native platform calls + // and can take ~100ms+; during auto-ping bursts multiple rebuilds + // would otherwise schedule overlapping runs whose cleanup loops + // would remove symbols the other sync just added. + if (_syncInFlight) return; + _syncInFlight = true; + try { + await _syncAllAnnotations(appState); + } catch (e) { + debugError('[MAP] _syncAllAnnotations failed: $e'); + } finally { + _syncInFlight = false; + if (mounted) { + final freshVersion = _computeMarkerDataVersion(appState); + if (freshVersion != _lastMarkerDataVersion) { + setState(() {}); + } + } } }); } } - final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + // (GPS marker puck sync moved into _handleGpsPosition — runs every tick.) + + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; // Get safe area padding for dynamic island/notch in landscape final safePadding = MediaQuery.of(context).padding; final topPadding = isLandscape ? 16.0 : 8.0; @@ -566,156 +1780,4288 @@ class _MapWidgetState extends State with TickerProviderStateMixin { return Stack( children: [ - // Map - _buildMap(appState, center), - - // GPS Info overlay (top-left, respects dynamic island in landscape) - Positioned( - top: topPadding, - left: leftPadding, - child: _buildGpsInfoOverlay(appState), - ), + // Map — wait for Hive-loaded preferences before constructing + // MapLibreMap, otherwise the default mapStyle ('liberty') would + // render first and then swap to the user's saved style. + if (appState.preferencesLoaded) + _buildMap(appState, center) + else + const ColoredBox( + color: Color(0xFF1A1A1A), + child: SizedBox.expand(), + ), - // Map controls - top-right in both orientations, collapsible - Positioned( - top: topPadding, - right: 8, - child: _buildCollapsibleMapControls(appState), - ), + // GPS Info + Top Repeaters overlay (hidden during history view) + if (!appState.viewingHistorySession) + Positioned( + top: topPadding, + left: leftPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildGpsInfoOverlay(appState), + if (appState.preferences.showTopRepeaters) ...[ + const SizedBox(height: 6), + _buildTopRepeatersOverlay(appState), + ], + ], + ), + ), + + // Map controls (hidden during history view) + if (!appState.viewingHistorySession) + Positioned( + top: topPadding, + right: 8, + child: _buildCollapsibleMapControls(appState), + ), + + if (_consecutiveTileLoadFailures >= _tileLoadFailureThreshold) + Positioned( + top: topPadding, + left: 0, + right: 0, + child: Center( + child: _buildTileLoadFailedBanner(), + ), + ), + + // Minimized focus panel pill — the DEFAULT view for a ping tap (and what + // the full sheet collapses to). Gated on _focusedPingSource (not + // _focusedPingLocation) so a "missed" ping — heard by nobody, so no focus + // location/camera — still shows its pill. Full-width like the info pill so + // its 2-row layout has room. Not a modal: the map stays interactable. + if (_focusPanelMinimized && _focusedPingSource != null) + Positioned( + bottom: 16 + MediaQuery.of(context).padding.bottom, + left: 16, + right: 16, + child: _buildMinimizedFocusPanel(), + ), + + // Minimized cell-summary / repeater-detail pill — same idea as the focus + // pill, for the tap popups. Map stays interactive while it's shown. + if (_minimizedInfoPopup != null) + Positioned( + bottom: 16 + MediaQuery.of(context).padding.bottom, + left: 16, + right: 16, + child: _buildMinimizedInfoPill(_minimizedInfoPopup!), + ), + + // History session pill (bottom, styled like minimized focus panel). + // Hidden while a minimized focus/info pill occupies the same bottom + // slot, so the pill takes over the bottom area instead of stacking on + // top of (or under) this banner — parity with how the live-session + // control panel hides on infoPopupMinimized / isFocusModeActive. + if (appState.viewingHistorySession && + _minimizedInfoPopup == null && + !(_focusPanelMinimized && _focusedPingSource != null)) + Positioned( + bottom: 16 + MediaQuery.of(context).padding.bottom, + left: 16, + right: 16, + child: Center( + child: _buildHistoryBanner(appState), + ), + ), ], ); } - /// Collapsible map controls (toggle at top, expands downward) - Widget _buildCollapsibleMapControls(AppStateProvider appState) { - // Use external state if provided, otherwise use internal state - final isExpanded = widget.mapControlsExpanded ?? _mapControlsExpanded; - final onToggle = widget.onMapControlsToggle ?? () => setState(() => _mapControlsExpanded = !_mapControlsExpanded); + /// Banner shown when map tiles fail to load within the timeout window. + Widget _buildTileLoadFailedBanner() { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 80), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.orange.shade900.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange.shade700, width: 1), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.cloud_off, color: Colors.white, size: 16), + SizedBox(width: 8), + Flexible( + child: Text( + 'Map tiles unavailable — check connection', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } + + /// Pill shown at the bottom of the map when viewing a history session + Widget _buildHistoryBanner(AppStateProvider appState) { + final count = appState.historySessionMarkers?.length ?? 0; + return GestureDetector( + onTap: () => appState.clearHistorySession(), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.history, + color: Theme.of(context).colorScheme.primary, + size: 18, + ), + const SizedBox(width: 8), + Text( + 'Session History', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(width: 8), + Text( + '$count events', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: () => appState.clearHistorySession(), + child: Icon( + Icons.close, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } + + /// Collapsible map controls (toggle at top, expands downward) + Widget _buildCollapsibleMapControls(AppStateProvider appState) { + // Use external state if provided, otherwise use internal state + final isExpanded = widget.mapControlsExpanded ?? _mapControlsExpanded; + final onToggle = widget.onMapControlsToggle ?? + () => setState(() => _mapControlsExpanded = !_mapControlsExpanded); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Toggle button (always visible) - at top + GestureDetector( + onTap: onToggle, + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: isExpanded + ? const BorderRadius.vertical(top: Radius.circular(8)) + : BorderRadius.circular(8), + ), + child: Icon( + isExpanded ? Icons.expand_less : Icons.expand_more, + color: Colors.white, + size: 22, + ), + ), + ), + // Map controls (only when expanded) - below the toggle button + if (isExpanded) _buildMapControls(appState), + ], + ); + } + + /// Method channel to the iOS native bridge (AppDelegate.swift). iOS + /// maplibre_gl 0.25.0 has no `setOffline` implementation, so we ship our + /// own: a URLProtocol that fails tile requests fast while offline mode is + /// engaged, letting MapLibre-iOS render only its cached tiles. + static const _iosOfflineChannel = MethodChannel('meshmapper/ios_map_offline'); + + /// Toggle MapLibre between online (network tiles) and offline (cache-only). + /// Android uses the plugin's native `setOffline`; iOS uses our bridge. + Future _setOfflineIfSupported(bool offline) async { + if (kIsWeb) return; + try { + if (Platform.isIOS) { + await _iosOfflineChannel + .invokeMethod('setOffline', {'offline': offline}); + } else { + await setOffline(offline); + } + debugLog('[MAP] setOffline($offline) — ' + 'tiles ${offline ? "cache-only" : "enabled"}'); + } catch (e) { + debugWarn('[MAP] setOffline failed: $e'); + } + } + + Widget _buildMap(AppStateProvider appState, LatLng center) { + final mapStyle = + MapStyleExtension.fromString(appState.preferences.mapStyle); + // Always use the real style so downloaded offline tiles can render from + // cache. Network access is controlled via setOffline() instead. + final newStyleUrl = mapStyle.styleUrl; + + // Detect mapTilesEnabled toggle changes and switch MapLibre between + // online (network tiles) and offline (cache-only) mode. This avoids + // a full style reload — the same style stays loaded but MapLibre stops + // or starts making network requests for tiles. + // + // When disabling tiles, also drop the coverage overlay source/layer: + // MapLibre's ambient cache would still serve previously-fetched tiles, + // but the overlay is a live-data layer and shouldn't linger on the map + // in an indeterminate half-cached state once the user opted out. When + // re-enabling, re-add it so it reappears without waiting for a style + // reload or cache-bust. + final tilesEnabled = appState.preferences.mapTilesEnabled; + if (_lastMapTilesEnabled != tilesEnabled && _isMapReady) { + final wasEnabled = _lastMapTilesEnabled; + _lastMapTilesEnabled = tilesEnabled; + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + await _setOfflineIfSupported(!tilesEnabled); + if (wasEnabled == true && !tilesEnabled) { + await _removeCoverageOverlay(); + // No overlay on the map -> the post-wardrive fresh-tile flow must + // not keep rendering server tiles for it. + appState.reportVectorOverlayActive(false); + } else if (wasEnabled == false && tilesEnabled && _styleLoaded) { + await _addCoverageOverlay(appState); + } + }); + } + + // Style changes flow through MapLibreMap.styleString — the plugin's + // didUpdateWidget detects the new value and fires a native setStyle. + // onStyleLoadedCallback → _onStyleLoaded re-registers images, rebuilds + // cluster layers, re-adds the coverage overlay, and re-syncs annotations. + + // Detect zoneCode or overlay-pref changes → schedule a SINGLE coalesced + // refresh; the _coverageRefreshScheduled flag ensures at most one refresh + // is queued per frame even when both change together. + // + // The zoneCode watcher is needed because _addCoverageOverlay only runs + // during _onStyleLoaded — if the first zone check failed with + // gps_inaccurate, the style loads with zoneCode=null and the overlay is + // skipped. When a later retry sets the zone, nothing else would trigger + // the coverage layer. + final zoneChanged = appState.zoneCode != _lastOverlayZoneCode && + _isMapReady && + _styleLoaded; + // Grid size is baked into the tile URL and the CVD palette into the layer + // paint, so either change rebuilds the overlay. + final prefsForOverlay = appState.preferences; + final overlayPrefChanged = _isMapReady && + _styleLoaded && + ((_lastAppliedGridSize != null && + _lastAppliedGridSize != prefsForOverlay.coverageGridSize) || + (_lastAppliedCvd != null && + _lastAppliedCvd != prefsForOverlay.colorVisionType)); + // A Grid Mode change ALSO toggles repeater clustering (Detailed = 100 is + // un-clustered). `cluster` is fixed at source creation, so the repeater + // source/layers must be rebuilt — not just the coverage overlay. + final gridChanged = _isMapReady && + _styleLoaded && + _lastAppliedGridSize != null && + _lastAppliedGridSize != prefsForOverlay.coverageGridSize; + + if (zoneChanged || overlayPrefChanged) { + if (zoneChanged) { + _lastOverlayZoneCode = appState.zoneCode; + // The session patch belongs to the old region's grid. + appState.clearCoveragePatch(); + } + if (overlayPrefChanged) { + // Patched cell ids/geometry are per grid preset; a palette-only + // change keeps them (the rebuild restyles the patch layer too). + if (gridChanged) { + appState.clearCoveragePatch(); + } + // An open community coverage view (cell fan-out / repeater cells) is + // tied to the old grid steps/blob and palette — clear it so it doesn't + // render stale geometry/colours over the rebuilt overlay. + _clearCoverageConnections(); + _lastAppliedGridSize = prefsForOverlay.coverageGridSize; + _lastAppliedCvd = prefsForOverlay.colorVisionType; + } + if (!_coverageRefreshScheduled) { + _coverageRefreshScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) async { + _coverageRefreshScheduled = false; + if (!mounted) return; + // Rebuild the repeater source/layers with the new cluster flag BEFORE + // the coverage overlay refresh — coverage targets the bottom repeater + // layer as its belowLayerId, so those layers must exist first. Collapse + // any open spider so its (cluster-only) state doesn't leak across the + // rebuild. + if (gridChanged && _clusterLayersReady) { + _collapseSpider(); + _clusterLayersReady = false; + await _setupRepeaterClusterLayers( + clustered: appState.preferences.coverageGridSize != 100); + await _syncRepeaterSymbols(appState); + } + await _refreshCoverageOverlay(appState); + // Region borders sit below the repeater stack; re-add them so they + // stay beneath the freshly-rebuilt repeater layers. + if (gridChanged && _showRegionBorders) { + await _refreshRegionBorders(appState); + } + }); + } + } + + // Watch for region boundary polygon changes. Signature is derived from + // the polygon codes AND point counts so zone transfers (same code, new + // shape) and refreshes both trigger a rebuild. + final borders = appState.regionBorders; + final bordersSig = Object.hashAll(borders.map( + (p) => Object.hash(p['code'], (p['polygon'] as List?)?.length ?? 0), + )); + if (bordersSig != _lastBordersSignature && _isMapReady && _styleLoaded) { + _lastBordersSignature = bordersSig; + if (_showRegionBorders) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _refreshRegionBorders(appState); + }); + } + } + + // (Session-patch application: direct provider listener + // _onCoveragePatchNotify — see initState for why it's not a build watcher.) + + // (Coverage overlay opacity: direct provider listener + // _onCoverageOpacityNotify, for the same reason. Opacity is UI-only state + // that must not bump mapRevision, so a build() watcher here would never + // run when the slider moves.) + + return Stack( + children: [ + // MapLibre GL map (base tiles via style; coverage overlay added programmatically) + MapLibreMap( + styleString: newStyleUrl, + initialCameraPosition: CameraPosition( + target: center, + zoom: _defaultZoom, + ), + minMaxZoomPreference: const MinMaxZoomPreference(3, _maxUserZoom), + rotateGesturesEnabled: !_rotationLocked, + scrollGesturesEnabled: true, + zoomGesturesEnabled: true, + tiltGesturesEnabled: false, // 2D wardriving map + compassEnabled: false, // We have our own controls + // CRITICAL: must be true so the controller's `cameraPosition` getter + // stays synced with the platform side. Without this, the Dart-side + // _cameraPosition is set once at construction and never updated, which + // breaks our sync projection (markers project to stale positions and + // get filtered out by viewport bounds). Also enables camera-move events + // during gestures so _onCameraChanged fires every frame for live + // marker overlay updates. + trackCameraPosition: true, + onMapCreated: _onMapCreated, + onStyleLoadedCallback: () => _onStyleLoaded(appState), + onMapIdle: _onMapIdle, + onCameraIdle: _onCameraIdle, + // onMapClick fires ONLY for taps that DON'T hit an interactive + // layer (the iOS/Android plugin routes feature hits to + // `feature#onTap` and doesn't dispatch onMapClick in that case). + // That's exactly what we need for the empty-area-collapse path — + // tapping the map background closes any open spider. + // Custom-layer feature taps still flow through + // `controller.onFeatureTapped` (registered in _onMapCreated). + onMapClick: _onMapEmptyTap, + ), + // No widget marker overlay — markers are now native MapLibre + // annotations rendered by the platform view itself. + ], + ); + } + + void _onMapCreated(MapLibreMapController controller) { + _mapController = controller; + // Wire up native annotation tap callbacks. These streams fire when the + // user taps on a symbol/line that the platform-side hit-test matches. + // Since the controller is created exactly once, this listener registration + // happens exactly once too — no need to remove and re-add on style switch. + controller.onSymbolTapped.add(_handleSymbolTap); + // Generic feature tap handler — fires for ANY interactive style layer, + // including our custom repeater cluster + individual layers (which are + // NOT managed by the annotation manager). We dispatch in _handleFeatureTap + // based on the layerId. + controller.onFeatureTapped.add(_handleFeatureTap); + } + + /// Routes a native symbol tap to the appropriate detail sheet. + /// The tap event carries the [Symbol] object, which has the metadata Map we + /// attached when calling addSymbol() in the various sync methods. We use the + /// `kind` and `id` keys to look up the original ping/repeater object from + /// app state and call the existing `_show*Details()` method (which expects + /// the full object, not just an ID). + void _handleSymbolTap(Symbol symbol) { + if (!mounted) return; + final data = symbol.data; + if (data == null) return; + final kind = data['kind'] as String?; + final id = data['id']; + final appState = context.read(); + + switch (kind) { + // 'repeater' is no longer handled here — repeaters are in a custom + // cluster GeoJSON layer (not the annotation manager) and dispatch + // through _handleMapClick + queryRenderedFeatures instead. + case 'tx': + final ping = appState.txPings + .where((p) => p.timestamp.millisecondsSinceEpoch == id) + .firstOrNull; + if (ping != null) _showTxPingDetails(ping); + break; + case 'rx': + final ping = appState.rxPings + .where((p) => p.timestamp.millisecondsSinceEpoch == id) + .firstOrNull; + if (ping != null) _showRxPingDetails(ping); + break; + case 'disc': + final entry = appState.discLogEntries + .where((e) => e.timestamp.millisecondsSinceEpoch == id) + .firstOrNull; + if (entry != null) _showDiscPingDetails(entry); + break; + case 'trace': + final entry = appState.traceLogEntries + .where((e) => e.timestamp.millisecondsSinceEpoch == id) + .firstOrNull; + if (entry != null) _showTraceDetails(entry); + break; + case 'history_tx': + case 'history_rx': + case 'history_disc': + case 'history_trace': + final marker = appState.historySessionMarkers + ?.where((m) => m.timestamp.millisecondsSinceEpoch == id) + .firstOrNull; + if (marker != null) _showHistoryMarkerAsLive(marker); + break; + } + } + + /// Fires for taps on the map background that don't hit any interactive + /// custom-layer feature. Used to close an open spider when the user taps + /// somewhere outside the spread / cluster — the standard "click empty + /// area to dismiss" interaction. Wired via the MapLibreMap `onMapClick` + /// parameter. + void _onMapEmptyTap(math.Point point, LatLng coordinates) { + if (!mounted) return; + if (_spiderCenter != null) { + _collapseSpider(); + return; // dismissing the spider shouldn't also open a cell summary + } + // Safety net for a stranded isolation (active but no sheet and no pill + // keeping the selection alive). While minimized to a pill, the repeater + // stays the active selection, so leave the others hidden — don't restore. + if (_isolatedRepeaterId != null && _minimizedInfoPopup == null) { + _clearRepeaterIsolation(); // restore hidden repeaters, don't open a cell + return; + } + // Safety net for a stranded Feature A fan-out (faded repeaters + lines but + // no pill keeping the cell summary alive) — full teardown (clears the + // footprint/dim/fade and restores the shared focus camera) on empty tap. + if (_coverageHeardRepeaterIds != null && _minimizedInfoPopup == null) { + _clearCellHighlight(); + return; + } + // Fallback cell hit-test: some platforms don't dispatch coverage fill-layer + // taps through onFeatureTapped, so query the coverage layer at the tap point. + _maybeShowCellSummaryAt(point, coordinates); + } + + /// Hit-test the active coverage fill layer around [point]; if a cell is there, + /// open its GRID SUMMARY. The onMapClick fallback to _handleFeatureTap. + Future _maybeShowCellSummaryAt( + math.Point point, LatLng coordinates) async { + final layerId = _activeCoverageLayerId; + if (layerId == null || _mapController == null) return; + try { + // Hit-test a small BOX around the tap, not the exact pixel: at low zoom a + // coverage cell is only a pixel or two wide (a 300 m cell ≈ 2 px at z9), so + // an exact-point query nearly always lands in a gap and the tap "doesn't + // register". A ~30 px box makes tiles tappable at any zoom. + const pad = 15.0; + final rect = + Rect.fromLTWH(point.x - pad, point.y - pad, pad * 2, pad * 2); + // Include the session patch layer (the user's own freshly-pinged cells, + // drawn on top of the base) so tapping a patched cell still hit-tests. + final layers = [layerId]; + if (_patchLayerReady) layers.add(_patchLayerId); + final features = + await _mapController!.queryRenderedFeaturesInRect(rect, layers, null); + if (!mounted || features.isEmpty) return; + _showCellSummary(coordinates); + } catch (e) { + debugWarn('[COVERAGE] cell hit-test failed: $e'); + } + } + + /// Open the GRID SUMMARY bottom sheet for the coverage cell at [coordinates]. + /// Lazily fetches the cell's coverage points and aggregates them client-side, + /// mirroring the web cell-click popup (minus PING HISTORY). + void _showCellSummary(LatLng coordinates) { + if (!mounted || _cellSummaryShowing) return; + // Don't open a cell summary when zoomed too far out — cells are barely + // visible there, so taps are almost always accidental. Both tap paths + // (_handleFeatureTap + _maybeShowCellSummaryAt) funnel here, so this one + // guard covers them and also skips the network fetch below. + final zoom = _mapController?.cameraPosition?.zoom ?? 0; + if (zoom < _kMinCellTapZoom) { + debugLog('[COVERAGE] cell tap ignored — zoom ' + '${zoom.toStringAsFixed(2)} < $_kMinCellTapZoom'); + return; + } + final appState = context.read(); + final zone = appState.zoneCode; + if (zone == null || zone.isEmpty) return; + + final gridSize = appState.preferences.coverageGridSize; + // Snap to the clicked CELL (same grid as the server/web) so the summary is + // identical anywhere in the cell and matches the web's lazyShowPingsAt. + final steps = kCoverageGridSteps[gridSize] ?? const [0.0009, 0.00128]; + final cell = GridCell.containing( + coordinates.latitude, coordinates.longitude, steps[0], steps[1]); + // The Detailed (100 m) coverage tile smears each ping over a 3×3 cell block + // (server $blob=1); Simplified (300 m) doesn't (blob=0). So a green cell can + // be coloured by a ping up to blob cells away — fetch out to the block's far + // corner and keep the pings whose blob covers this cell, else a blob-painted + // neighbour cell falsely reads "no coverage data here" (matches the web's + // ($gsize === 100 ? 1 : 0) blob rule for both presets). + final blob = gridSize == 100 ? 1 : 0; + final radius = cell.blobFetchRadiusMeters(blob, gridSize.toDouble()); + final lookup = RepeaterLookup.fromRepeaters(appState.repeaters, + hopBytes: appState.effectiveHopBytes); + final isImperial = appState.preferences.isImperial; + + debugLog('[COVERAGE] cell tap @ ' + '${coordinates.latitude.toStringAsFixed(5)},' + '${coordinates.longitude.toStringAsFixed(5)} ' + 'cell=${cell.i}/${cell.j} blob=$blob r=${radius.toStringAsFixed(0)}m'); + + // Supersede a currently-minimized popup. If it was a cell, its footprint + // stays until this tap's own footprint replaces it (no bright flash); if it + // was a repeater, drop the pill AND restore the hidden repeaters. + // Mark the cell view active BEFORE the teardowns so the shared focus camera + // isn't restored mid-switch (it stays engaged and re-fits below). + _cellPopupActive = true; + _clearMinimizedInfoPopup(); + _clearRepeaterIsolation(); + // Switching from a minimized ping focus: tear it down so its pill doesn't + // linger and overlap this cell view's pill at the shared bottom slot + // (symmetric to _enterPingFocus, which tears down the coverage views). The + // cell view is already claimed (_cellPopupActive = true), so this dismiss's + // _exitFocusCameraIfDone no-ops and the shared focus camera stays engaged. + _dismissPingFocus(); + _enterFocusCamera(); + + // Fetch the pings once, keep the ones whose blob covers the tapped cell, and + // drive BOTH the summary sheet and the footprint highlight off that list. + final Future>> blobPointsFuture = appState + .fetchCellCoverage( + lat: cell.centerLat, + lon: cell.centerLon, + radiusMeters: radius, + ) + .then((points) => cell.filterWithinBlob(points, blob)); + + // Footprint highlight + backdrop dim appear with the data (web parity with + // highlightSpotCoverage(points)): the block fills in the dominant colour of + // the in-blob pings. No pings → no footprint, no dim (web early-return). + blobPointsFuture.then((pts) { + if (!mounted || !_cellPopupActive) return; + final st = dominantCoverageStatus(pts); + if (st != null) _showCellFootprint(cell, blob, st); + }).catchError((Object e) { + debugWarn('[COVERAGE] cell highlight failed: $e'); + }); + + // Feature A: fan out a dashed line from the cell centre to every UNIQUE + // repeater that heard the cell's pings (web `updateAllActiveLines` parity), + // hide the repeaters that didn't, and label each line with its distance. + // Lines are theme-aware blue (web keys off the basemap; we key off the + // Flutter theme). Read brightness before the async gap. + final fanColor = + Theme.of(context).brightness == Brightness.dark ? '#4da6ff' : '#00008b'; + blobPointsFuture.then((pts) { + if (!mounted || !_cellPopupActive) return; + final eps = _capByFarthest( + heardEndpointsForCell(pts, lookup, + startLat: cell.centerLat, startLon: cell.centerLon), + (e) => e.distanceMeters, + 250, + '[COVERAGE] cell fan-out', + ); + final segments = [ + for (final e in eps) (lat: e.lat, lon: e.lon, color: fanColor) + ]; + _updateCoverageLines(segments, cell.centerLat, cell.centerLon); + _syncCoverageDistanceLabels( + segments, cell.centerLat, cell.centerLon, isImperial); + // Empty -> null restores all repeaters (never hide-all on an empty set). + _coverageHeardRepeaterIds = + eps.isEmpty ? null : {for (final e in eps) e.repeaterId}; + _syncRepeaterSymbols(appState); + // Match ping focus: frame the cell + the repeaters that heard it (no-op + // when nothing was heard — single point — leaving the north-up view). + _fitCameraToPoints([ + LatLng(cell.centerLat, cell.centerLon), + for (final e in eps) LatLng(e.lat, e.lon), + ]); + }).catchError((Object e) { + debugWarn('[COVERAGE] cell fan-out failed: $e'); + }); + + final Future summaryFuture = blobPointsFuture + .then((pts) => GridSummary.fromPoints(pts, lookup)) + .catchError((Object e) { + debugWarn('[COVERAGE] cell summary failed: $e'); + return null; + }); + + // Open minimized by default — a compact stats pill; tap it to expand to the + // full GRID SUMMARY sheet. + _minimizeCellSummary( + cell: cell, + blob: blob, + summaryFuture: summaryFuture, + isImperial: isImperial, + ); + } + + /// Present (or re-present) the cell GRID SUMMARY sheet for [cell]. Bright map + /// (transparent barrier so the footprint dim still pops) + a minimize button + /// that collapses to a tappable pill, mirroring the ping-focus sheets. The + /// footprint highlight + dim stay while minimized and are cleared on a real + /// close. Reused by both the initial tap and the pill's reshow. + void _presentCellSummarySheet({ + required GridCell cell, + required int blob, + required Future summaryFuture, + required bool isImperial, + }) { + _cellSummaryShowing = true; + showModalBottomSheet( + context: context, + useSafeArea: true, + // Transparent barrier so the map stays bright (footprint dim still pops). + barrierColor: Colors.transparent, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (ctx) => CellSummarySheet( + summaryFuture: summaryFuture, + isImperial: isImperial, + onMinimize: () => Navigator.pop(ctx, 'minimized'), + ), + ).then((result) { + _cellSummaryShowing = false; + if (!mounted) return; + if (result == 'minimized') { + // Collapse back to the stats pill (keep the footprint). + _minimizeCellSummary( + cell: cell, + blob: blob, + summaryFuture: summaryFuture, + isImperial: isImperial, + ); + } else { + _clearCellHighlight(); + } + }); + } + + /// Handles taps on custom layer features (repeater cluster bubbles and + /// individual repeaters). Wired in [_onMapCreated] via + /// `controller.onFeatureTapped.add(_handleFeatureTap)`. + /// + /// The iOS/Android tap dispatcher calls this for ANY tap that hits an + /// interactive style layer, BEFORE falling back to `onMapClick`. Since our + /// cluster source layers are interactive, taps on repeaters/clusters always + /// route here (not through onMapClick). + /// + /// We dispatch by [layerId]: + /// - cluster bubble layer → zoom in 2 levels around the tap point + /// - individual repeater layer → look up the Repeater by id and open the + /// existing detail sheet + /// + /// [id] is the GeoJSON Feature `id` (which we set to `repeater.id` for + /// individual repeaters; MapLibre auto-generates one for cluster features). + /// [annotation] is always null here since these layers aren't managed by + /// the annotation manager. + void _handleFeatureTap( + math.Point point, + LatLng coordinates, + String id, + String layerId, + Annotation? annotation, + ) { + if (!mounted) return; + + // Spider spread marker: the user has picked one of the fanned-out repeaters + // to inspect → collapse the spider and focus that repeater (focus mode + its + // coverage cells), mirroring the GPS fall-through path in + // _fallThroughToRepeaterAt. + if (layerId == _spiderSymbolLayerId) { + _collapseSpider(); + _showRepeaterDetailsById(id); // isolate: true (default) + return; + } + + // Cluster tap: zoom in by default, but spiderfy first when the cluster + // contains markers stacked tightly enough that no further zoom would + // separate them. We accept hits on either the bubble circle layer or + // the count-text symbol layer (either may win the platform-side + // top-down hit-test depending on tap position). + // + // The explicit 200ms duration is important for perceived responsiveness. + // Without it, iOS uses setCamera(animated: true) which has a slow ease-in + // start (~150ms before any noticeable motion). Passing a duration switches + // the native code path to fly(to:withDuration:) which ramps in faster and + // finishes in 200ms, making the tap feel "instant" rather than delayed. + if (layerId == _repeaterClusterBubbleLayerId || + layerId == _repeaterClusterCountLayerId) { + _handleClusterBubbleTap(point, coordinates); + return; + } + + // Individual repeater: open the detail sheet. We ALSO check for stacked + // siblings within the spider stick threshold and spread them out. In + // Simplified Mode this is gated to max zoom (clustered, so the bubble-tap + // path zooms in first; an individual hit below max zoom is a real lone + // marker). In Detailed Mode there is no cluster bubble / "zoom in first" + // path and stacked markers overlap at every zoom, so spiderfy fires at any + // zoom — otherwise a stacked pile can only ever show one repeater's coverage + // and the rest stay buried. + if (layerId == _repeaterIndividualLayerId) { + // If a spider is open and the user tapped a non-spider individual + // (originals are filtered out via `inSpider`, so this can only be a + // marker outside the spider's group), collapse the existing spider. + if (_spiderCenter != null) { + _collapseSpider(); + } + + final appState = context.read(); + final detailed = appState.preferences.coverageGridSize == 100; + if (_isAtMaxZoom() || detailed) { + final group = _findSpiderGroup(coordinates, appState); + if (group.length >= 2) { + _spiderfy(coordinates, group); + return; + } + } + + _showRepeaterDetailsById(id); + return; + } + + // Regional boundary: either the line or the label → info dialog. + if (layerId == _regionBorderLineLayerId || + layerId == _regionBorderLabelLayerId) { + _showBorderInfoDialog(); + return; + } + + // Coverage cell: open the GRID SUMMARY. Coverage fills sit below the + // repeaters, so reaching one means no repeater/cluster was hit. Accept ALL + // coverage fill layers, not just the base overlay: the session patch layer + // (the user's own freshly-pinged cells) renders ON TOP of the base, so a tap + // on a patched cell — common when zoomed in near where you've wardriven — + // returns the patch layer id and would otherwise be a dead tap. The tapped + // footprint highlight and the repeater coverage cells are likewise tappable. + // Platforms that don't dispatch fill-layer taps fall back to the + // queryRenderedFeatures hit-test in _onMapEmptyTap. + if (layerId == _activeCoverageLayerId || + layerId == _patchLayerId || + layerId == _cellHighlightLayerId || + layerId == _coverageCellsLayerId) { + _showCellSummary(coordinates); + return; + } + + // GPS marker tap: the GPS marker is a non-interactive symbol on the + // annotation manager layer (which sits ON TOP of all custom layers in + // paint order). Without intervention, taps on the GPS marker hit the + // annotation layer first and stop the iOS dispatcher from checking the + // cluster layers underneath. Detect that case here and re-query the + // cluster layers at the same screen point so the user can still tap + // a cluster/repeater that the GPS marker happens to be sitting on top of. + if (annotation is Symbol) { + final kind = annotation.data?['kind'] as String?; + if (kind == 'gps') { + _fallThroughToRepeaterAt(point, coordinates); + return; + } + } + } + + /// Handle a tap on a cluster bubble (or its count label). Spiderfy is gated + /// to max zoom; below that we just zoom in. + /// + /// At max zoom, the spider group is computed from the actual MapLibre + /// cluster's `point_count` (queried back from the rendered feature) — we + /// take the N nearest repeaters to the tapped coordinate. This deliberately + /// avoids the transitive single-link clustering used by [_findSpiderGroup], + /// which could chain across separate visual clusters when markers form a + /// 42m-spaced trail and pull every chained marker into a single spider. + Future _handleClusterBubbleTap( + math.Point point, LatLng coordinates) async { + if (!mounted) return; + + // Below max zoom: zoom in further so the user has a chance to separate + // the stack visually before we resort to the spread UI. _spiderCenter is + // always null at non-max zoom (the camera-change collapse fires when the + // user zooms out), so no collapse-handling is needed here. + if (!_isAtMaxZoom()) { + if (_canAnimateCamera && + isValidLatLng(coordinates.latitude, coordinates.longitude)) { + final currentZoom = + _mapController?.cameraPosition?.zoom ?? _defaultZoom; + final newZoom = math.min(currentZoom + 2, _maxUserZoom); + _mapController?.animateCamera( + CameraUpdate.newLatLngZoom(coordinates, newZoom), + duration: const Duration(milliseconds: 200), + ); + } + return; + } + + // Read the tapped cluster's point_count from MapLibre. This is the + // authoritative count of leaves Supercluster grouped into this bubble — + // matching it ensures the spider expands exactly the markers represented + // by the tapped bubble, not a chained connected component. + int? pointCount; + try { + final features = await _mapController?.queryRenderedFeatures( + point, + const [ + _repeaterClusterBubbleLayerId, + _repeaterClusterCountLayerId, + ], + null, + ); + if (features != null) { + for (final f in features) { + final props = ((f as Map)['properties'] as Map?) ?? const {}; + if (props['cluster'] == true) { + final pc = props['point_count']; + if (pc is num) { + pointCount = pc.toInt(); + break; + } + } + } + } + } catch (e) { + debugError('[MAP] cluster point_count query failed: $e'); + } + + if (!mounted) return; + + final appState = context.read(); + // If we couldn't read point_count (race with style reload, etc.), fall + // back to the BFS-based group — better to spiderfy something than nothing. + final group = pointCount != null + ? _findSpiderGroupForCluster(coordinates, pointCount, appState) + : _findSpiderGroup(coordinates, appState); + + // Re-tap on the open spider's own group → collapse instead of churn. + if (_spiderCenter != null) { + final spiderIds = _spiderRepeaters.map((r) => r.id).toSet(); + if (group.any((r) => spiderIds.contains(r.id))) { + _collapseSpider(); + return; + } + _collapseSpider(); + } + + if (group.length >= 2) { + _spiderfy(coordinates, group); + } + // Already at max zoom with a single-marker group: nothing useful to + // zoom into and no stack to spread. Silent no-op. + } + + /// When a tap hits the GPS marker (which has no detail sheet), try to find + /// any repeater cluster or individual repeater under the same point and + /// dispatch THAT instead. We use [queryRenderedFeatures] explicitly scoped + /// to the cluster source's layers, since the iOS native tap dispatcher + /// already short-circuited at the GPS marker layer above. + Future _fallThroughToRepeaterAt( + math.Point point, + LatLng coordinates, + ) async { + if (_mapController == null) return; + try { + // Query the spider symbols FIRST so a tap on a spread marker hidden + // under the GPS overlay still routes to the spider's detail sheet + // (rather than the original repeater layer beneath it). + final features = await _mapController!.queryRenderedFeatures( + point, + const [ + _spiderSymbolLayerId, + _repeaterClusterCountLayerId, + _repeaterClusterBubbleLayerId, + _repeaterIndividualLayerId, + ], + null, + ); + if (features.isEmpty || !mounted) return; + + // The Dart-side wrapper jsonDecodes each feature into a Map for us + // (see method_channel_maplibre_gl.dart::queryRenderedFeatures). So we + // can read properties directly without parsing JSON. + final feature = features.first as Map; + final properties = (feature['properties'] as Map?) ?? {}; + + // Cluster (auto-tagged by MapLibre when cluster: true is set on source). + // Mirrors the cluster path in _handleFeatureTap, including the + // max-zoom gate on spiderfy. We already have the feature in hand here, + // so read `point_count` directly instead of re-querying. + if (properties['cluster'] == true) { + if (!_isAtMaxZoom()) { + if (_canAnimateCamera && + isValidLatLng(coordinates.latitude, coordinates.longitude)) { + final currentZoom = + _mapController?.cameraPosition?.zoom ?? _defaultZoom; + final newZoom = math.min(currentZoom + 2, _maxUserZoom); + _mapController?.animateCamera( + CameraUpdate.newLatLngZoom(coordinates, newZoom), + duration: const Duration(milliseconds: 200), + ); + } + return; + } + final appState = context.read(); + final pcRaw = properties['point_count']; + final pointCount = pcRaw is num ? pcRaw.toInt() : null; + final group = pointCount != null + ? _findSpiderGroupForCluster(coordinates, pointCount, appState) + : _findSpiderGroup(coordinates, appState); + if (_spiderCenter != null) { + final spiderIds = _spiderRepeaters.map((r) => r.id).toSet(); + if (group.any((r) => spiderIds.contains(r.id))) { + _collapseSpider(); + return; + } + _collapseSpider(); + } + if (group.length >= 2) { + _spiderfy(coordinates, group); + } + return; + } + + // Individual repeater (cluster or spider symbol). The feature `id` + // field is the repeater.id we set in our feature builders. Spider + // symbols never need spiderfy expansion — they ARE the spread; just + // open the detail sheet and leave the spider open. + final repeaterId = + (feature['id'] ?? properties['repeaterId'])?.toString(); + if (repeaterId == null) return; + + // For an individual layer hit (not a spider symbol), apply the same + // stacked-siblings test as the direct tap path. queryRenderedFeatures + // doesn't expose layerId per result in 0.25, so we infer: if a spider is + // open, the original individuals are filtered out, so any individual hit + // must be a non-stacked marker → just open the detail sheet. Otherwise run + // the spiderfy test — gated to max zoom in Simplified Mode, but at any + // zoom in Detailed Mode (un-clustered, so stacked markers overlap at every + // zoom and have no cluster-bubble "zoom in first" path). Mirrors the + // direct tap path. + if (_spiderCenter != null) { + // User tapped outside the open spider's group — collapse + show + // detail sheet for the tapped marker. + _collapseSpider(); + } else { + final appState = context.read(); + final detailed = appState.preferences.coverageGridSize == 100; + if (_isAtMaxZoom() || detailed) { + final group = _findSpiderGroup(coordinates, appState); + if (group.length >= 2) { + _spiderfy(coordinates, group); + return; + } + } + } + _showRepeaterDetailsById(repeaterId); + } catch (e) { + debugError('[MAP] queryRenderedFeatures fall-through failed: $e'); + } + } + + /// Open the repeater detail sheet for a given [repeaterId]. Looks up the + /// Repeater object from app state and recomputes the duplicate/hopOverride + /// flags. Used by both direct tap dispatch and the GPS fall-through path. + void _showRepeaterDetailsById(String repeaterId, {bool isolate = true}) { + if (!mounted) return; + final appState = context.read(); + final repeater = + appState.repeaters.where((r) => r.id == repeaterId).firstOrNull; + if (repeater == null) return; + + final duplicates = _getDuplicateRepeaterIds(_mapVisibleRepeaters(appState)); + final isDuplicate = duplicates.contains(repeater.id); + final hopOverride = + appState.enforceHopBytes ? appState.effectiveHopBytes : null; + + _showRepeaterDetails( + repeater, + isDuplicate: isDuplicate, + regionHopBytesOverride: hopOverride, + isolate: isolate, + ); + } + + /// Restore all repeaters after a repeater isolation (web-parity "click a + /// repeater hides the rest"). No-op if nothing is isolated. No setState — + /// nothing in build() reads [_isolatedRepeaterId]; the source is pushed + /// imperatively, matching the focus/spider resyncs. + void _clearRepeaterIsolation() { + if (_isolatedRepeaterId == null) return; + _isolatedRepeaterId = null; + // Tear down any Feature B coverage cells/lines drawn for the selection and + // restore the base coverage tiles it dimmed. + _clearCoverageLines(); + _clearCoverageCells(); + _restoreCoverageBackdropForRepeater(); + if (!mounted) return; + _syncRepeaterSymbols(context.read()); + // Restore the shared focus camera if no focus view remains (no-op during a + // switch, where the incoming view is already marked active). + _exitFocusCameraIfDone(); + } + + /// Restore the base coverage overlay opacity dimmed by a Feature B repeater + /// coverage draw (honours ping focus, which keeps the overlay hidden at 0). + /// No-op when not dimmed. + void _restoreCoverageBackdropForRepeater() { + if (!_coverageDimmedForRepeater) return; + _coverageDimmedForRepeater = false; + if (!mounted) return; + final restore = _focusedPingLocation != null + ? 0.0 + : context.read().preferences.coverageOverlayOpacity; + _applyCoverageOverlayOpacity(restore); + } + + Future _onStyleLoaded(AppStateProvider appState) async { + // Re-entrance guard. iOS plugin sometimes fires onStyleLoadedCallback + // multiple times during a single setStyle. The race causes "Layer not + // found" errors during the symbol manager's _rebuildLayers and + // double-registers images. Bail any nested call so the first invocation + // runs to completion uninterrupted. + if (_styleLoadInProgress) { + debugLog( + '[MAP] _onStyleLoaded re-entered while already running, skipping'); + return; + } + _styleLoadInProgress = true; + try { + _styleLoaded = true; + _isMapReady = true; + + // Let the GL surface render one frame before allowing camera animations. + // Without this, constrainCameraAndZoomToBounds can produce NaN on the + // very first flyTo/easeTo after style load (the viewport may be + // zero-sized or the projection matrix degenerate). + _cameraAnimationReady = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _cameraAnimationReady = true; + } + }); + + // CRITICAL: clear stale Symbol references from any previous style load. + // Style reloads cause maplibre_gl to construct a brand-new SymbolManager + // with empty internal _idToAnnotation maps. Our _coverageSymbols / + // _distanceLabelSymbols still reference the OLD + // Symbol objects whose IDs are not in the new manager — calling + // updateSymbol on them throws "you can only set existing annotations". + // Clearing them now means the next sync will call addSymbol (which + // creates fresh symbols in the new manager) instead of updateSymbol. + // The dedicated GPS-puck source/layer is wiped by the style reload too; + // reset the guard so _ensureGpsPuckLayer recreates it (topmost) below. + _gpsPuckLayerInstalled = false; + _coverageSymbols.clear(); + _coverageSymbolSig.clear(); + // The native SymbolManager is rebuilt empty here, so the next sync re-adds + // every symbol via addSymbol. Drop the stale z-order map so re-added keys + // get fresh (higher) counter values and stay monotonic with re-creation + // order — but keep _coverageZCounter climbing (never reset it). + _coverageZIndex.clear(); + _distanceLabelSymbols.clear(); + // Distance-label companions: the native side wipes registered images on + // style reload, so the "already registered" cache must be cleared too or + // the next focus mode will skip addImage() and reference a non-existent + // image. The size/repeater-position maps are cleared for consistency. + _distanceLabelImageSize.clear(); + _distanceLabelRepeaterPos.clear(); + _registeredDistanceLabelImages.clear(); + _registeredDistanceLabelImageSizes.clear(); + // Detailed-mode baked repeater chips are dropped by the native side on + // style reload too — clear the cache so the next sync re-registers them. + _registeredChipImages.clear(); + // Mark cluster layers as not-ready until _setupRepeaterClusterLayers + // creates them on the new style. This gates build()-driven post-frame + // syncs from racing ahead of source creation. + _clusterLayersReady = false; + + // Style reload wipes the native layer stack, so any tracked coverage + // overlay IDs now reference layers that no longer exist. Reset them so + // the next refresh treats this as a fresh add. + _activeCoverageSourceId = null; + _activeCoverageLayerId = null; + // Same reasoning for the focus-lines source/layers — gone with the + // style. Clear the flag so _updateFocusLines won't try to remove + // already-gone layers next time it's called. + _focusLinesInstalled = false; + // The tap cell-highlight overlay is likewise gone; _addCoverageOverlay + // re-installs it below. + _cellHighlightReady = false; + // Community coverage line/cell layers + their tracked symbols/images are + // also gone with the style; reset so the next draw treats it as fresh. + _coverageLinesInstalled = false; + _coverageCellsInstalled = false; + _coverageHeardRepeaterIds = null; + _coverageDistanceLabelSymbols.clear(); + _registeredCoverageLabelImages.clear(); + _registeredCoverageLabelImageSizes.clear(); + + // Disable symbol decluttering on the annotation manager. By default, + // MapLibre symbol layers hide overlapping icons/labels at lower zoom to + // reduce visual clutter — but for wardriving we want every coverage + // marker visible regardless of density. (Repeaters are now in their own + // cluster-enabled GeoJSON layer with its own per-layer overlap settings.) + await _configureSymbolDecluttering(); + + // Pre-render and register all marker bitmaps for native annotations. + // Style reloads (e.g., user switches dark→liberty) wipe registered images, + // so we always re-register on every style load. Awaited so the cluster + // layer (which references icon image names) sees them when it's created. + _imagesRegistered = false; + await _registerMapImages(appState); + + // Set up the repeater source + layers. Must run AFTER images are + // registered, since the individual symbol layer's iconImage expression + // looks up names registered by _registerMapImages. Clustering follows the + // Grid Mode pref: Detailed (gsize 100) renders every repeater individually. + await _setupRepeaterClusterLayers( + clustered: appState.preferences.coverageGridSize != 100); + + // Re-add coverage overlay AFTER cluster layers exist so _addCoverageOverlay + // can target the bottom repeater layer as its belowLayerId reference. This + // keeps the insertion point consistent with the zoneCode watcher path — + // both end up with raster at the bottom of the repeater stack, not above it. + await _refreshCoverageOverlay(appState); + _lastOverlayZoneCode = appState.zoneCode; + + // Regional boundary layer — style reload wipes custom sources/layers. + // Reset the signature so the build()-driven watcher will repaint even + // if the polygon list hasn't changed (it almost always hasn't). + _lastBordersSignature = -1; + await _refreshRegionBorders(appState); + + // GPS puck: dedicated top-most source+layer. Install AFTER coverage + + // repeaters + borders so it sits above them all (always-on-top by layer + // order). Idempotent; _syncGpsSymbol also ensures it before its first push. + await _ensureGpsPuckLayer(); + + // Start tile-load timeout. If onMapIdle doesn't fire within N seconds, + // we assume tiles are failing to load (network down, server error, etc.) + // and surface a banner. Cleared as soon as onMapIdle fires. + // When tiles are disabled (cache-only mode), suppress the warning — cached + // tiles load instantly or not at all; a timeout would be misleading. + _tileLoadTimeoutTimer?.cancel(); + final tilesEnabled = appState.preferences.mapTilesEnabled; + _lastMapTilesEnabled = tilesEnabled; + // Ensure MapLibre offline mode matches the user's preference. + _setOfflineIfSupported(!tilesEnabled); + if (tilesEnabled) { + _tileLoadTimeoutTimer = + Timer(const Duration(seconds: _tileLoadTimeoutSeconds), () { + if (!mounted) return; + _consecutiveTileLoadFailures++; + debugWarn( + '[MAP] Tile load timeout — tiles did not finish loading within ${_tileLoadTimeoutSeconds}s ($_consecutiveTileLoadFailures/$_tileLoadFailureThreshold)'); + if (_consecutiveTileLoadFailures >= _tileLoadFailureThreshold) { + appState.logError( + 'Map tiles unavailable — check connection', + severity: ErrorSeverity.warning, + autoSwitch: false, + ); + setState(() {}); + } + }); + } else { + _consecutiveTileLoadFailures = 0; + } + + // First-load-only setup: center on GPS and register camera listener. + // On subsequent style switches, preserve the user's pan position. + if (!_hasStyleLoadedOnce) { + _hasStyleLoadedOnce = true; + + // Center on GPS if available (initial centering). Skip on invalid + // coords — an out-of-range/NaN LatLng aborts the app in MapLibre. + final stylePos = appState.currentPosition; + if (stylePos != null && + isValidLatLng(stylePos.latitude, stylePos.longitude) && + _canAnimateCamera) { + final center = LatLng(stylePos.latitude, stylePos.longitude); + _mapController!.animateCamera( + CameraUpdate.newLatLngZoom(center, _defaultZoom), + ); + } + + // Register camera listener ONCE so marker overlay positions update on pan/zoom + _mapController!.addListener(_onCameraChanged); + } + + // Force an initial annotation sync now that images are registered AND the + // cluster source/layers exist. This pushes the current app state into the + // newly-created native annotations on first style load (and again whenever + // the style is reloaded, since style reloads wipe everything). + if (mounted) { + await _syncAllAnnotations(appState); + // Update the data version to match what we just synced. Without this, + // the build()-driven post-frame sync would fire AGAIN with the same + // data because _lastMarkerDataVersion still holds the previous value + // — that double-sync was racing the first sync's symbol refs and + // throwing "you can only set existing annotations" errors twice. + _lastMarkerDataVersion = _computeMarkerDataVersion(appState); + // The GPS-only sync gate needs no capture here: _syncAllAnnotations + // ran _syncGpsSymbol, which records the version itself when its push + // lands (and deliberately doesn't when it bails, so the build-driven + // sync retries). + if (mounted) setState(() {}); + } + } finally { + _styleLoadInProgress = false; + } + } + + /// Fires when the map finishes loading visible tiles and the camera is idle. + /// We use this as the "tiles loaded successfully" signal — clears the failure + /// timer and hides any tile-load warning banner. Also releases any pending + /// coverage-overlay swap waiter so the previous buffer can be retired now + /// that the new tiles have rendered. + void _onMapIdle() { + _tileLoadTimeoutTimer?.cancel(); + + // First idle = the GL surface has rendered → the native viewport is now + // valid and camera animations are safe (see _mapHasRenderedOnce). Nudge a + // rebuild so the deferred one-shot initial zoom (held while the map was not + // yet rendered) re-attempts via build()'s _handleGpsPosition safety-net. + if (!_mapHasRenderedOnce && mounted) { + _mapHasRenderedOnce = true; + debugLog('[MAP] First map idle — camera animations enabled'); + setState(() {}); + } + + if (_consecutiveTileLoadFailures > 0 && mounted) { + if (_consecutiveTileLoadFailures >= _tileLoadFailureThreshold) { + debugLog( + '[MAP] Tiles recovered after $_consecutiveTileLoadFailures consecutive load failures'); + } + _consecutiveTileLoadFailures = 0; + setState(() {}); + } + } + + /// Fires when the camera stops moving — after both gestures and + /// programmatic animations. Auto-follow uses [_autoFollowDesiredZoom] as + /// a *one-shot* zoom target: tap-to-follow sets it to [_maxUserZoom], + /// the resulting animateCamera lands at that zoom, and this idle handler + /// then clears it. Subsequent GPS ticks fall through to the camera's + /// current zoom (line ~1005), so the user can pinch-zoom freely while + /// auto-follow is on without each tick snapping the zoom back. + void _onCameraIdle() { + if (!_autoFollow || _mapController == null) return; + _autoFollowDesiredZoom = null; + } + + /// Add MeshMapper coverage raster overlay as a MapLibre source+layer. + /// Allocates fresh suffixed IDs each call to avoid native collisions. + /// Gated entry point; see [_coverageGate]. + Future _addCoverageOverlay(AppStateProvider appState) => + _coverageGate.run(() => _addCoverageOverlayLocked(appState)); + + Future _addCoverageOverlayLocked(AppStateProvider appState) async { + if (_mapController == null || !_showMeshMapperOverlay) { + debugLog( + '[MAP] Coverage overlay add skipped: controller=${_mapController != null}, showOverlay=$_showMeshMapperOverlay'); + return; + } + if (!appState.preferences.mapTilesEnabled) { + debugLog('[MAP] Coverage overlay add skipped: mapTilesEnabled=false'); + return; + } + if (appState.zoneCode == null || appState.zoneCode!.isEmpty) { + debugLog( + '[MAP] Coverage overlay add skipped: zoneCode=${appState.zoneCode}'); + return; + } + + final prefs = appState.preferences; + final zone = appState.zoneCode!.toLowerCase(); + final gridSize = prefs.coverageGridSize; + + // Replace, never stack: if an overlay is already up (double-triggered + // add, resume racing a rebuild), tear it down first or the old + // source+layer pair leaks untracked on the map. + if (_activeCoverageSourceId != null) { + await _removeCoverageOverlayLocked(); + } + + final sourceId = _nextCoverageSourceId(); + final layerId = _coverageLayerIdFor(sourceId); + + try { + // Target the bottom of the repeater cluster stack when it exists, so the + // overlay lands beneath ALL marker layers (repeater clusters + symbol + // annotations). Fallback to the symbol annotation layer if cluster layers + // haven't been created yet. + final belowLayer = _clusterLayersReady + ? _repeaterIndividualLayerId + : _symbolAnnotationLayerId(); + // While ping focus mode is active, force the newly added layer to + // opacity 0 so an overlay rebuild (zone/grid/palette change) doesn't + // make the overlay pop back into view in the middle of focus mode. + // Dismissing focus restores the preference value via + // _applyCoverageOverlayOpacity in _dismissPingFocus. + final opacity = + _focusedPingLocation != null ? 0.0 : prefs.coverageOverlayOpacity; + + // The server emits an integer status category per cell (st); colour + // happens HERE via match expressions, so colour-vision palettes apply + // without any server param and a tile carries data, not pixels. + final url = + 'https://$zone.meshmapper.net/vector_tile.php?z={z}&x={x}&y={y}&gsize=$gridSize'; + // minzoom 7 = the raster's old on-screen range (512px-convention vector + // tiles sit one display-zoom lower than 256px raster tiles). + await _mapController!.addSource( + sourceId, + VectorSourceProperties(tiles: [url], minzoom: 7, maxzoom: 14), + ); + await _mapController!.addFillLayer( + sourceId, + layerId, + FillLayerProperties( + fillColor: + CoverageTilePalette.fillColorExpression(prefs.colorVisionType), + fillOutlineColor: + CoverageTilePalette.borderColorExpression(prefs.colorVisionType), + fillOpacity: opacity, + ), + sourceLayer: 'coverage', + belowLayerId: belowLayer, + ); + _activeCoverageSourceId = sourceId; + _activeCoverageLayerId = layerId; + + // The session patch rides directly above the base layer (same anchor, + // added later = higher) with identical styling. Creation is + // self-healing: if it fails here, the next patch update retries it. + // The base-layer filter is only applied once the patch layer exists — + // hiding cells the patch can't draw would punch holes in the overlay. + _patchLayerReady = false; + if (await _ensureCoveragePatchLayer(appState)) { + _lastAppliedPatchVersion = appState.coveragePatchVersion; + await _applyBasePatchFilter(appState, layerId); + } + + // Tap-to-highlight overlay: installed empty here (renders nothing until a + // cell tap pushes a block into it). Self-heals on first tap if this fails. + _cellHighlightReady = false; + await _ensureCellHighlightLayer(); + + _lastAppliedCoverageOpacity = opacity; + _lastAppliedGridSize = gridSize; + _lastAppliedCvd = prefs.colorVisionType; + appState.reportVectorOverlayActive(true); + debugLog( + '[MAP] Coverage overlay added as $layerId (grid $gridSize, below ${belowLayer ?? "top"}, opacity ${opacity.toStringAsFixed(2)})'); + } catch (e) { + // No overlay on the map — the post-wardrive fresh-tile flow must not + // keep fetching for it. + appState.reportVectorOverlayActive(false); + debugLog('[MAP] Failed to add coverage overlay: $e'); + } + } + + String _nextCoverageSourceId() => + 'meshmapper-overlay-${++_coverageBufferCounter}'; + + String _coverageLayerIdFor(String sourceId) => '$sourceId-layer'; + + /// Apply a new coverage overlay opacity to the live fill layers without + /// removing/re-adding them. No-op if the layer doesn't exist yet. + /// + /// setLayerProperties serializes with skipNulls:false — every property left + /// null is RESET to its style-spec default on iOS/web (fill-color → black), + /// so the full colour expressions must be resent alongside the new opacity, + /// never a partial FillLayerProperties. + Future _applyCoverageOverlayOpacity(double opacity) async { + if (_mapController == null || !mounted) return; + final layerId = _activeCoverageLayerId; + if (layerId == null) return; + try { + final cvd = context.read().preferences.colorVisionType; + final props = FillLayerProperties( + fillColor: CoverageTilePalette.fillColorExpression(cvd), + fillOutlineColor: CoverageTilePalette.borderColorExpression(cvd), + fillOpacity: opacity, + ); + if (_patchLayerReady) { + await _mapController!.setLayerProperties(_patchLayerId, props); + } + await _mapController!.setLayerProperties(layerId, props); + _lastAppliedCoverageOpacity = opacity; + debugLog( + '[MAP] Coverage overlay opacity updated to ${opacity.toStringAsFixed(2)}'); + } catch (e) { + // Layer may not exist yet (e.g. before first style load or when the + // overlay is hidden). Safe to ignore — next _addCoverageOverlay call + // will pick up the current preference value. + debugLog('[MAP] Coverage overlay opacity update skipped: $e'); + } + } + + /// Empty FeatureCollection — used to clear a GeoJSON source without removing + /// its layer (an empty source renders nothing, costs nothing). + Map _emptyFeatureCollection() => + {'type': 'FeatureCollection', 'features': []}; + + /// Filled FeatureCollection for the tapped cell's (2·blob+1)² block — one + /// Polygon per cell (nine in Detailed, one in Simplified), centred on the + /// tapped tile. Drawn in a single uniform colour to mirror the web's nine + /// `L.rectangle`s. + Map _buildCellFootprintGeoJson(GridCell cell, int blob) => + { + 'type': 'FeatureCollection', + 'features': [ + for (final ring in cell.blockCellPolygons(blob)) + { + 'type': 'Feature', + 'properties': {}, + 'geometry': { + 'type': 'Polygon', + 'coordinates': [ring], + }, + }, + ], + }; + + /// Idempotently (re)create the tap cell-highlight source + fill layer. Nothing + /// renders while the source is empty (the idle state); colours are set per tap + /// in [_showCellFootprint]. Self-healing like the patch layer. + Future _ensureCellHighlightLayer() async { + if (_cellHighlightReady) return true; + if (_mapController == null) return false; + try { + final belowLayer = _clusterLayersReady + ? _repeaterIndividualLayerId + : _symbolAnnotationLayerId(); + try { + await _mapController!.removeLayer(_cellHighlightLayerId); + } catch (_) {} + try { + await _mapController!.removeSource(_cellHighlightSourceId); + } catch (_) {} + await _mapController! + .addGeoJsonSource(_cellHighlightSourceId, _emptyFeatureCollection()); + await _mapController!.addFillLayer( + _cellHighlightSourceId, + _cellHighlightLayerId, + // Placeholder colours; the per-tap dominant colour is pushed in + // _showCellFootprint. Full opacity so the footprint pops over the + // dimmed coverage backdrop. + const FillLayerProperties( + fillColor: '#bd2130', + fillOutlineColor: '#8b101b', + fillOpacity: 1.0, + ), + belowLayerId: belowLayer, + ); + _cellHighlightReady = true; + return true; + } catch (e) { + debugLog('[COVERAGE] cell-highlight layer create failed: $e'); + return false; + } + } + + /// Fill the tapped [cell]'s block in the [st] dominant colour and dim the + /// coverage backdrop so the footprint stands out (web `highlightSpotCoverage` + /// parity). [blob] = 1 in Detailed (3×3), 0 in Simplified (single cell). + Future _showCellFootprint(GridCell cell, int blob, int st) async { + if (_mapController == null || !mounted) return; + // Read the palette before any await so context isn't used across an async gap. + final cvd = context.read().preferences.colorVisionType; + final colors = CoverageTilePalette.colorsForStatus(cvd, st); + if (!await _coverageGate.run(_ensureCellHighlightLayer)) return; + try { + // setLayerProperties serializes with skipNulls:false (resets omitted + // fields to spec defaults), so resend all three fill props together. + await _mapController!.setLayerProperties( + _cellHighlightLayerId, + FillLayerProperties( + fillColor: colors[0], + fillOutlineColor: colors[1], + fillOpacity: 1.0, + ), + ); + await _mapController!.setGeoJsonSource( + _cellHighlightSourceId, _buildCellFootprintGeoJson(cell, blob)); + // Dim the coverage backdrop (base + patch) beneath the bright footprint. + _coverageDimmedForCell = true; + await _applyCoverageOverlayOpacity(_kCellHighlightFadeOpacity); + } catch (e) { + debugLog('[COVERAGE] cell-highlight set failed: $e'); + } + } + + /// Clear the tap highlight (summary sheet closed): empty the source so the + /// layer renders nothing, and restore the dimmed coverage backdrop. The layer + /// stays installed (cheap, no churn). + Future _clearCellHighlight() async { + _cellPopupActive = false; + // Tear down the Feature A fan-out (lines + distance labels) and restore any + // repeaters faded to the heard set whenever the cell summary closes. Also + // drop any Feature B cells defensively (cheap no-op when none). + _restoreFadedRepeaters(); + _clearCoverageLines(); + _clearCoverageCells(); + _clearCoverageDistanceLabels(); + if (_coverageDimmedForCell) { + _coverageDimmedForCell = false; + // Restore the backdrop, honouring ping-focus mode (which keeps it hidden). + if (mounted) { + final restore = _focusedPingLocation != null + ? 0.0 + : context + .read() + .preferences + .coverageOverlayOpacity; + await _applyCoverageOverlayOpacity(restore); + } + } + // Restore the shared focus camera if no focus view remains (a no-op during a + // switch, where the incoming view is already marked active). Runs before the + // early-return below so it always fires. + _exitFocusCameraIfDone(); + if (_mapController == null || !_cellHighlightReady) return; + try { + await _mapController! + .setGeoJsonSource(_cellHighlightSourceId, _emptyFeatureCollection()); + } catch (e) { + debugLog('[COVERAGE] cell-highlight clear failed: $e'); + } + } + + /// Returns the layer ID of the symbol annotation manager's first (and only) + /// layer, or `null` if the manager isn't initialized yet. Used as a + /// `belowLayerId` reference to insert other layers (coverage overlay, focus + /// lines) BENEATH the marker symbols so markers always render on top. + String? _symbolAnnotationLayerId() { + final manager = _mapController?.symbolManager; + if (manager == null) return null; + return '${manager.id}_0'; + } + + /// Disables MapLibre's default symbol-collision behavior for our marker + /// annotations. Without this, repeater markers fade out as you zoom out + /// because the symbol layer hides overlapping icons + labels to reduce + /// visual clutter — undesirable for a wardriving app where every marker + /// matters. Called once per style load, before any symbols are added. + Future _configureSymbolDecluttering() async { + if (_mapController == null) return; + try { + await _mapController!.setSymbolIconAllowOverlap(true); + await _mapController!.setSymbolIconIgnorePlacement(true); + await _mapController!.setSymbolTextAllowOverlap(true); + await _mapController!.setSymbolTextIgnorePlacement(true); + } catch (e) { + debugError('[MAP] Failed to configure symbol decluttering: $e'); + } + } + + /// Remove the active coverage overlay source and layer (if any) and clear + /// the tracked IDs. Called by the mapTilesEnabled-toggle teardown and on + /// style reload — it does NOT participate in the double-buffer swap path. + /// Gated entry point; see [_coverageGate]. + Future _removeCoverageOverlay() => + _coverageGate.run(_removeCoverageOverlayLocked); + + Future _removeCoverageOverlayLocked() async { + final layerId = _activeCoverageLayerId; + final sourceId = _activeCoverageSourceId; + _activeCoverageLayerId = null; + _activeCoverageSourceId = null; + // No live layer means nothing is applied. Leaving this set would keep + // _onCoverageOpacityNotify calling into a guaranteed no-op on every notify + // while the overlay is off. _addCoverageOverlay sets it again. + _lastAppliedCoverageOpacity = null; + if (layerId != null && sourceId != null) { + await _removeCoverageLayerById(layerId, sourceId); + } + await _removeCoveragePatchLayer(); + } + + /// Remove a specific coverage source+layer pair without touching the + /// active-ID tracking. + Future _removeCoverageLayerById(String layerId, String sourceId) async { + if (_mapController == null) return; + try { + await _mapController!.removeLayer(layerId); + } catch (_) {} + try { + await _mapController!.removeSource(sourceId); + } catch (_) {} + } + + /// Remove the session-patch source+layer (vector overlay companion). + Future _removeCoveragePatchLayer() async { + _patchLayerReady = false; + if (_mapController == null) return; + try { + await _mapController!.removeLayer(_patchLayerId); + } catch (_) {} + try { + await _mapController!.removeSource(_patchSourceId); + } catch (_) {} + } + + /// GeoJSON FeatureCollection of the session patch: one rectangle per cell, + /// corners computed from grid indices exactly like the server's tiles. + Map _buildPatchGeoJson(AppStateProvider appState) { + final steps = kCoverageGridSteps[appState.preferences.coverageGridSize] ?? + kCoverageGridSteps[300]!; + final features = >[]; + for (final cell in appState.coveragePatchCells.values) { + final lat0 = cell.i * steps[0]; + final lon0 = cell.j * steps[1]; + // No feature id: nothing reads it on the patch layer, and 42-bit ids + // don't survive Android's float32 JSON bridge anyway (see + // _applyBasePatchFilter). + features.add({ + 'type': 'Feature', + 'properties': {'st': cell.st}, + 'geometry': { + 'type': 'Polygon', + 'coordinates': [ + [ + [lon0, lat0], + [lon0 + steps[1], lat0], + [lon0 + steps[1], lat0 + steps[0]], + [lon0, lat0 + steps[0]], + [lon0, lat0], + ] + ], + }, + }); + } + return {'type': 'FeatureCollection', 'features': features}; + } + + /// Hide the BASE layer's copies of patched cells — the patch layer owns + /// them now; without the filter the translucent fills would stack and the + /// patched cells would render darker than their neighbours. + /// + /// Matched on an "i_j" string built from the small-int grid properties, NOT + /// on the feature id: Android's filter converter parses every JSON number + /// as float32, which silently rounds the 42-bit ids so an id-based `in` + /// never matches. Strings cross the bridge untouched on every platform. + Future _applyBasePatchFilter( + AppStateProvider appState, String baseLayerId) async { + if (_mapController == null || appState.coveragePatchCells.isEmpty) return; + final keys = [ + for (final cell in appState.coveragePatchCells.values) + '${cell.i}_${cell.j}' + ]; + try { + await _mapController!.setFilter(baseLayerId, [ + '!', + [ + 'in', + [ + 'concat', + [ + 'to-string', + ['get', 'i'] + ], + '_', + [ + 'to-string', + ['get', 'j'] + ], + ], + ['literal', keys], + ], + ]); + } catch (e) { + debugLog('[MAP] Coverage base filter failed: $e'); + } + } + + /// Idempotently (re)create the patch source+layer. Self-healing: if the + /// creation during overlay add failed, or a style change replaced the + /// native layers, the next patch application rebuilds it here. + Future _ensureCoveragePatchLayer(AppStateProvider appState) async { + if (_patchLayerReady) return true; + if (_mapController == null || _activeCoverageLayerId == null) return false; + try { + final prefs = appState.preferences; + final belowLayer = _clusterLayersReady + ? _repeaterIndividualLayerId + : _symbolAnnotationLayerId(); + final opacity = + _focusedPingLocation != null ? 0.0 : prefs.coverageOverlayOpacity; + await _removeCoveragePatchLayer(); + await _mapController! + .addGeoJsonSource(_patchSourceId, _buildPatchGeoJson(appState)); + await _mapController!.addFillLayer( + _patchSourceId, + _patchLayerId, + FillLayerProperties( + fillColor: + CoverageTilePalette.fillColorExpression(prefs.colorVisionType), + fillOutlineColor: + CoverageTilePalette.borderColorExpression(prefs.colorVisionType), + fillOpacity: opacity, + ), + belowLayerId: belowLayer, + ); + _patchLayerReady = true; + debugLog('[MAP] Coverage patch layer created'); + return true; + } catch (e) { + debugLog('[MAP] Coverage patch layer create failed: $e'); + return false; + } + } + + /// Push the latest session patch into the map: update the GeoJSON source + /// (flash-free, in-place) and extend the base-layer filter. Nothing else on + /// the map changes — this is the live-update path for the user's own pings. + /// Gated entry point; see [_coverageGate]. + Future _applyCoveragePatch(AppStateProvider appState) => + _coverageGate.run(() => _applyCoveragePatchLocked(appState)); + + Future _applyCoveragePatchLocked(AppStateProvider appState) async { + if (_mapController == null) return; + final baseLayerId = _activeCoverageLayerId; + if (baseLayerId == null) { + debugLog('[MAP] Coverage patch skipped: no active coverage layer'); + return; + } + if (!await _ensureCoveragePatchLayer(appState)) return; + try { + await _mapController! + .setGeoJsonSource(_patchSourceId, _buildPatchGeoJson(appState)); + await _applyBasePatchFilter(appState, baseLayerId); + debugLog( + '[MAP] Coverage patch applied: ${appState.coveragePatchCells.length} cell(s)'); + } catch (e) { + debugLog('[MAP] Coverage patch apply failed: $e'); + } + } + + /// Rebuild the coverage overlay (remove + re-add). Only runs on real + /// transitions — zone change, grid/palette pref change, style reload — + /// never as a live-refresh path (that's the session patch's job), so the + /// brief remove/add gap is acceptable. + Future _refreshCoverageOverlay(AppStateProvider appState) async { + // Two gate entries, not one: another queued op landing between them (a + // patch apply, a toggle) still sees a consistent overlay either way. + await _removeCoverageOverlay(); + await _addCoverageOverlay(appState); + } + + /// Returns the fill color for a repeater status keyword. + /// Mirrors the priority logic in [_getRepeaterMarkerColor]. + Color _repeaterStatusColor(String status) { + switch (status) { + case 'dup': + return PingColors.repeaterDuplicate; + case 'dead': + return PingColors.repeaterDead; + case 'new': + return PingColors.repeaterNew; + case 'active': + default: + return PingColors.repeaterActive; + } + } + + /// Returns the color for a coverage marker (TX/RX/DISC/Trace × success/fail). + Color _coverageStatusColor(String type, bool success) { + switch (type) { + case 'tx': + return success ? PingColors.txSuccess : PingColors.txFail; + case 'rx': + return PingColors.rx; + case 'disc': + return success ? PingColors.discSuccess : PingColors.discFail; + case 'trace': + return success ? Colors.cyan : Colors.grey; + default: + return Colors.grey; + } + } + + /// Returns the borderRadius value for a repeater shape based on hop_bytes. + /// Mirrors the values in the original `_buildRepeaterMarkers` (lines ~2390). + double _repeaterBorderRadius(int hopBytes) { + if (hopBytes >= 3) return 8; + if (hopBytes == 2) return 6; + return 4; + } + + /// Pre-renders and registers all marker bitmaps that the native MapLibre + /// symbols reference via `iconImage`. Called from [_onStyleLoaded] after the + /// style is ready (so addImage can succeed). Idempotent — safe to call again + /// if a style reload happens; addImage replaces existing entries by name. + /// + /// Generates: + /// - 12 repeater shape bitmaps (4 status colors × 3 hop_byte radii) — fixed + /// width 48px, the widest case (6-char hex IDs); shorter text is centered + /// by MapLibre's textField rendering. + /// - 8 coverage marker bitmaps for the user's currently-selected style. + /// - 6 GPS marker bitmaps (one per style). + /// + /// Marker style preference changes are handled separately by + /// [_reregisterCoverageImages] which only re-runs the coverage section. + Future _registerMapImages(AppStateProvider appState) async { + if (_mapController == null) return; + + try { + // 1. Repeater shapes — 12 variants + const repeaterSize = Size(48, 28); + for (final status in _MapImages.repeaterStatuses) { + final color = _repeaterStatusColor(status); + for (final hopBytes in _MapImages.repeaterHopBytes) { + final painter = _RepeaterShapePainter( + fillColor: color, + borderRadius: _repeaterBorderRadius(hopBytes), + ); + final bytes = await _renderPainterToPng(painter, repeaterSize); + await _mapController!.addImage( + _MapImages.repeater(status, hopBytes), + bytes, + ); + } + } + + // 2. Coverage markers — 8 variants for current style + await _registerCoverageImages(appState.preferences.markerStyle); + + // 3. GPS marker variants — 7 styles + const gpsSize = Size(48, 48); + final gpsPainters = { + 'arrow': const _ArrowPainter(), + 'car': const _CarMarkerPainter(), + 'bike': const _BikeMarkerPainter(), + 'boat': const _BoatMarkerPainter(), + 'walk': const _WalkMarkerPainter(), + 'dog': const _DogMarkerPainter(), + 'chomper': const _ChomperMarkerPainter(), + }; + for (final entry in gpsPainters.entries) { + final bytes = await _renderPainterToPng(entry.value, gpsSize); + await _mapController!.addImage(_MapImages.gps(entry.key), bytes); + } + + _imagesRegistered = true; + debugLog( + '[MAP] Registered ${_MapImages.repeaterStatuses.length * _MapImages.repeaterHopBytes.length} repeater + 8 coverage + ${gpsPainters.length} GPS marker images'); + // NOTE: do NOT trigger _syncAllAnnotations here. The repeater cluster + // source/layers haven't been created yet — _onStyleLoaded calls + // _setupRepeaterClusterLayers AFTER us, then triggers the initial sync + // once everything is in place. + } catch (e) { + debugError('[MAP] Failed to register marker images: $e'); + } + } + + /// Generates and registers the 8 coverage marker bitmaps for [styleName]. + /// Called from [_registerMapImages] on initial setup, and from the + /// preference-change handler when the user picks a different marker shape. + Future _registerCoverageImages(String styleName) async { + if (_mapController == null) return; + // 40×40 canvas with the 24×24 glyph centered inside it — the transparent + // padding enlarges the native symbol hit target (~40×40 px) without + // changing the visual marker size. Fixes finicky taps on small markers. + const coverageSize = Size(40, 40); + for (final type in _MapImages.coverageTypes) { + for (final success in [true, false]) { + final painter = _CoverageMarkerPainter( + style: styleName, + color: _coverageStatusColor(type, success), + ); + final bytes = await _renderPainterToPng(painter, coverageSize); + await _mapController!.addImage( + _MapImages.coverage(type, success), + bytes, + ); + } + } + _registeredCoverageStyle = styleName; + } + + /// Returns the status keyword used as the iconImage suffix for a repeater. + /// Mirrors the priority logic in [_getRepeaterMarkerColor]: duplicate > dead + /// > new > active. + String _repeaterStatusKey(Repeater repeater, bool isDuplicate) { + if (isDuplicate) return 'dup'; + if (repeater.isDead) return 'dead'; + if (repeater.isNew) return 'new'; + return 'active'; + } + + /// Ensures every baked repeater-chip image referenced by [featureCollection] + /// (Detailed grid mode) is registered via addImage. Deduped by image name — + /// each distinct `repchip_{status}_{hop}_{hex}` chip is baked at most once and + /// cached in [_registeredChipImages] (cleared on style reload). Driven off the + /// FeatureCollection itself so the registered set can't drift from what the + /// layer actually references. The status/hop/hex are parsed back out of the + /// (controlled, underscore-free) image name to re-derive colour + radius. + Future _ensureRepeaterChipImages( + Map featureCollection) async { + if (_mapController == null) return; + final features = + (featureCollection['features'] as List?) ?? const []; + var baked = 0; + for (final f in features) { + final props = (f as Map)['properties'] as Map?; + final name = props?['iconImage'] as String?; + if (name == null || + !name.startsWith('repchip_') || + _registeredChipImages.contains(name)) { + continue; + } + // repchip_{status}_{hop}_{hex} — status/hex are underscore-free. + final parts = name.split('_'); + if (parts.length != 4) continue; + final status = parts[1]; + final hop = int.tryParse(parts[2]) ?? 1; + final hex = parts[3]; + try { + final bytes = await _renderRepeaterChipPng( + hex, + _repeaterStatusColor(status), + _repeaterBorderRadius(hop), + ); + await _mapController!.addImage(name, bytes); + _registeredChipImages.add(name); + baked++; + } catch (e) { + debugError('[MAP] render/addImage(repeater chip $name) failed: $e'); + } + } + if (baked > 0) { + debugLog('[MAP] Baked $baked new repeater chip image(s); ' + '${_registeredChipImages.length} total registered'); + } + } + + /// Converts a Flutter [Color] to a `#RRGGBB` (or `#RRGGBBAA`) hex string + /// for MapLibre symbol/line properties (which take CSS-style color strings). + String _colorToHex(Color color, {bool includeAlpha = false}) { + final argb = color.toARGB32() & 0xFFFFFFFF; + final rr = ((argb >> 16) & 0xFF).toRadixString(16).padLeft(2, '0'); + final gg = ((argb >> 8) & 0xFF).toRadixString(16).padLeft(2, '0'); + final bb = (argb & 0xFF).toRadixString(16).padLeft(2, '0'); + if (includeAlpha) { + final aa = ((argb >> 24) & 0xFF).toRadixString(16).padLeft(2, '0'); + return '#$rr$gg$bb$aa'; + } + return '#$rr$gg$bb'; + } + + /// Builds a GeoJSON FeatureCollection of all repeaters in app state, with + /// per-feature properties used by the data-driven symbol layer expressions + /// (iconImage, color, opacity, hex). Re-pushed to the cluster source whenever + /// the marker data version changes — MapLibre handles re-clustering natively. + Map _buildRepeaterFeatureCollection( + AppStateProvider appState) { + final visible = _mapVisibleRepeaters(appState); + final duplicates = _getDuplicateRepeaterIds(visible); + final hopOverride = + appState.enforceHopBytes ? appState.effectiveHopBytes : null; + // Detailed (gsize 100) is un-clustered, so each feature references a baked + // chip image (hex baked in). Simplified reuses the 12 shared shape images + // + a text-field hex label. See _setupRepeaterClusterLayers. + final detailed = appState.preferences.coverageGridSize == 100; + final focusActive = _focusedPingLocation != null; + // While a spider is open, repeaters in the spread set are tagged with + // `inSpider:true`. The individual symbol layer's filter excludes those + // features so the spread markers from `_spiderSourceId` render in their + // place. Cluster aggregation is not affected — the cluster bubble keeps + // its full point_count. + final spiderIds = _spiderRepeaters.map((r) => r.id).toSet(); + + final features = >[]; + for (final repeater in visible) { + // Repeater isolation: while a repeater is focused/selected, hide every + // other repeater entirely (skip the feature, so they also drop out of + // cluster counts) — same approach as focus mode below. Restored on close + // by _clearRepeaterIsolation. + if (_isolatedRepeaterId != null && repeater.id != _isolatedRepeaterId) { + continue; + } + // Feature A (tile fan-out): when a tapped cell's heard-repeater set is + // active and no single repeater is isolated, hide every repeater that did + // NOT hear the cell's pings (the web fades-but-keeps; we hide, matching + // focus/isolation). Cleared by _restoreFadedRepeaters. The set holds + // lowercased ids (from RepeaterLookup), so compare lowercased. + if (_coverageHeardRepeaterIds != null && + _isolatedRepeaterId == null && + !_coverageHeardRepeaterIds!.contains(repeater.id.toLowerCase())) { + continue; + } + final isDuplicate = duplicates.contains(repeater.id); + final statusKey = _repeaterStatusKey(repeater, isDuplicate); + final isConnected = focusActive && + _focusedRepeaters.any((r) => r.repeater.id == repeater.id); + // In focus mode, hide repeaters not involved in the focused ping entirely + // (skip the feature) rather than dimming — cleaner focus view and prevents + // them from contributing to clusters. + if (focusActive && !isConnected) continue; + final effectiveBytes = hopOverride ?? repeater.hopBytes; + // Clamp to the 1/2/3 hop_byte image variants we registered + final shapeBytes = effectiveBytes >= 3 + ? 3 + : effectiveBytes == 2 + ? 2 + : 1; + final hex = repeater.displayHexId(overrideHopBytes: hopOverride); + // Detailed: per-(status,hop,hex) baked chip (hex baked in); Simplified: + // shared shape image + a text-field hex label. _ensureRepeaterChipImages + // registers the chip lazily before the source is pushed. + final iconImage = detailed + ? _MapImages.repeaterChip(statusKey, shapeBytes, hex) + : _MapImages.repeater(statusKey, shapeBytes); + final colorHex = _colorToHex(_repeaterStatusColor(statusKey)); + + features.add({ + 'type': 'Feature', + 'id': repeater.id, + 'properties': { + 'repeaterId': repeater.id, + 'iconImage': iconImage, + 'color': colorHex, + 'hex': hex, + 'isDuplicate': isDuplicate, + if (hopOverride != null) 'hopOverride': hopOverride, + if (spiderIds.contains(repeater.id)) 'inSpider': true, + }, + 'geometry': { + 'type': 'Point', + // GeoJSON convention: [longitude, latitude] + 'coordinates': [repeater.lon, repeater.lat], + }, + }); + } + + return {'type': 'FeatureCollection', 'features': features}; + } + + /// Creates the repeater GeoJSON source and its rendering layers (individual + /// symbols, cluster bubble circles, cluster count text, spider line/symbols). + /// Called once per style load AFTER images are registered, and again whenever + /// Grid Mode changes (the `cluster` flag is fixed at source creation, so the + /// source must be rebuilt to toggle it). + /// + /// [clustered] follows Grid Mode: Simplified (gsize 300) clusters; Detailed + /// (gsize 100) does NOT — every repeater renders individually. The bubble / + /// count / spider layers are still created when un-clustered, but stay inert + /// (no `point_count` features ever exist, the spider source stays empty) so + /// the layer ids that other code paths reference — `queryRenderedFeatures`, + /// the region-border `belowLayerId` — remain valid in both modes. + /// + /// Styling differs by mode: clustered uses the cheap shared-glyph `textField` + /// hex label (clustering guarantees ≥50px spacing, so labels never overlap); + /// un-clustered uses the baked-in-icon chip (no `textField`) so an overlapping + /// chip's label can't detach onto a neighbour's box. + Future _setupRepeaterClusterLayers({required bool clustered}) async { + if (_mapController == null) return; + + // Idempotent: tear down any existing source/layers from a previous style load. + // Spider layers are torn down first because they reference `_spiderSourceId`. + for (final layerId in [ + _spiderSymbolLayerId, + _spiderLineLayerId, + _repeaterClusterCountLayerId, + _repeaterClusterBubbleLayerId, + _repeaterIndividualLayerId, + ]) { + try { + await _mapController!.removeLayer(layerId); + } catch (_) {} + } + try { + await _mapController!.removeSource(_repeaterSourceId); + } catch (_) {} + try { + await _mapController!.removeSource(_spiderSourceId); + } catch (_) {} + + // Shared symbol styling for the individual layer AND the spider symbol + // layer (the spider comment requires they look identical). Selected once by + // mode: Simplified keeps the shared-glyph text-field hex; Detailed bakes the + // hex into the chip image (no text-field, no iconColor — colour is baked in) + // so overlapping un-clustered chips can't have a label detach. iconSize 1.0 + // matches the distance-label baked-icon convention (DPR-3 PNG, centre + // anchor); the Simplified 48×28 shape keeps its existing 1.4 scale. + final SymbolLayerProperties repeaterSymbolProps = clustered + ? const SymbolLayerProperties( + iconImage: ['get', 'iconImage'], + iconColor: ['get', 'color'], + iconSize: 1.4, + iconAllowOverlap: true, + iconIgnorePlacement: true, + textField: ['get', 'hex'], + textColor: '#FFFFFF', + textHaloColor: '#000000', + textHaloWidth: 1.5, + textSize: 13, + textAllowOverlap: true, + textIgnorePlacement: true, + textFont: _defaultFontStack, + ) + : const SymbolLayerProperties( + iconImage: ['get', 'iconImage'], + iconSize: 1.0, + iconAllowOverlap: true, + iconIgnorePlacement: true, + ); + + // Empty source. We'll push real data via setGeoJsonSource from + // _syncRepeaterSymbols whenever the marker data version changes. `cluster` + // is fixed at creation, so a Grid Mode change rebuilds this whole source. + // + // IMPORTANT: pass `data` as a Dart Map (NOT jsonEncode-d string). The iOS + // plugin's `buildShapeSource` assumes that if `data` is a String, it must be + // a URL — and crashes via JSONSerialization.data() if a non-URL string is + // passed and the URL parse fails. Maps are handled correctly. + try { + await _mapController!.addSource( + _repeaterSourceId, + GeojsonSourceProperties( + data: const { + 'type': 'FeatureCollection', + 'features': [] + }, + // Simplified clusters; Detailed shows every repeater individually. + cluster: clustered, + clusterRadius: 50, + // Cluster at every reachable zoom (max user zoom is 17). Stacked + // markers — those within `clusterRadius` pixels at the current + // zoom — stay as a cluster bubble + count instead of degenerating + // into a pile of overlapping individual symbols when the user is + // zoomed all the way in. Tap a cluster → spiderfy. Markers far + // enough apart that they exceed `clusterRadius` pixels at higher + // zooms still separate into individuals naturally on zoom. Inert + // when cluster is false. + clusterMaxZoom: 17, + ), + ); + + // Place all three layers BELOW the symbol annotation manager so coverage + // markers / GPS / distance labels still render on top of repeater clusters. + final belowLayer = _symbolAnnotationLayerId(); + + // Layer 1: individual repeater markers (when not part of a cluster). + // Data-driven properties read from each feature's `properties` map. + // Filter excludes both clustered features AND any feature tagged + // `inSpider` (spiderfy hides the originals so the spread markers from + // _spiderSourceId render in their place). + await _mapController!.addSymbolLayer( + _repeaterSourceId, + _repeaterIndividualLayerId, + repeaterSymbolProps, + filter: [ + 'all', + [ + '!', + ['has', 'point_count'] + ], + [ + '!=', + ['get', 'inSpider'], + true + ], + ], + belowLayerId: belowLayer, + ); + + // Layer 2: cluster bubble (circle, sized by point_count). + // The 'step' expression makes the bubble grow as more repeaters merge: + // - default radius 18px (clusters of 2-9) + // - 22px for clusters of 10+ + // - 26px for clusters of 50+ + await _mapController!.addCircleLayer( + _repeaterSourceId, + _repeaterClusterBubbleLayerId, + CircleLayerProperties( + circleColor: _colorToHex(PingColors.repeaterActive), + circleRadius: const [ + 'step', + ['get', 'point_count'], + 18, + 10, + 22, + 50, + 26, + ], + circleStrokeColor: '#FFFFFF', + circleStrokeWidth: 2, + circleOpacity: 0.9, + ), + filter: ['has', 'point_count'], + belowLayerId: belowLayer, + ); + + // Layer 3: cluster count text (uses MapLibre's built-in + // 'point_count_abbreviated' property — automatically formatted as + // "1.2k" for large counts). + await _mapController!.addSymbolLayer( + _repeaterSourceId, + _repeaterClusterCountLayerId, + const SymbolLayerProperties( + textField: ['get', 'point_count_abbreviated'], + textColor: '#FFFFFF', + textSize: 14, + textHaloColor: '#000000', + textHaloWidth: 1, + textAllowOverlap: true, + textIgnorePlacement: true, + textFont: _defaultFontStack, + ), + filter: ['has', 'point_count'], + belowLayerId: belowLayer, + ); + + // Spider shadow source + layers — non-clustered. Carries spread Point + // features (one per spiderfied repeater) and LineString features for + // leader lines from the cluster centre to each spread position. + // Cluster on this source MUST stay false; we want every Point to render + // verbatim where _computeSpiderRing placed it. + await _mapController!.addSource( + _spiderSourceId, + const GeojsonSourceProperties( + data: { + 'type': 'FeatureCollection', + 'features': [] + }, + ), + ); + + // Layer 4: spider leader lines (LineString features). Inserted just + // below the individual repeater layer so lines render BENEATH every + // repeater layer — the cluster bubble (which sits above individuals) + // visually contains the lines' inner endpoints, and the spread markers + // (added next, above the annotation manager's siblings) hide the outer + // endpoints. Static filter selects LineString geometry only so Point + // features in the same source don't try to render as zero-length lines. + await _mapController!.addLineLayer( + _spiderSourceId, + _spiderLineLayerId, + const LineLayerProperties( + lineColor: '#888888', + lineWidth: 1.0, + lineOpacity: 0.7, + lineCap: 'round', + ), + filter: [ + '==', + ['geometry-type'], + 'LineString' + ], + belowLayerId: _repeaterIndividualLayerId, + ); + + // Layer 5: spider symbols (Point features). Same icon/text styling as + // the individual repeater layer so spread markers look identical to the + // originals. Inserted just below the symbol annotation manager — that + // puts it ABOVE the individual repeater layer (so spread markers win + // hit-tests against the now-hidden originals) but BELOW the GPS marker + // / coverage symbols on the annotation manager. iconAllowOverlap + + // iconIgnorePlacement are critical: without them MapLibre's collision + // detector hides adjacent spread markers and defeats the spread. + await _mapController!.addSymbolLayer( + _spiderSourceId, + _spiderSymbolLayerId, + repeaterSymbolProps, + filter: [ + '==', + ['geometry-type'], + 'Point' + ], + belowLayerId: belowLayer, + ); + + // All 3 layers + source + spider source/layers created successfully — + // mark ready so the build()-triggered post-frame sync can run, and so + // _syncRepeaterSymbols is allowed to push data via setGeoJsonSource. + _clusterLayersReady = true; + } catch (e) { + debugError('[MAP] Failed to set up repeater cluster layers: $e'); + } + } + + /// Pushes the current repeater state into the cluster source. MapLibre + /// re-clusters natively whenever the source data changes. Replaces the + /// previous per-symbol addSymbol/updateSymbol/removeSymbol diff loop. + /// + /// When a spider is open, also schedules a post-frame push of the spider + /// shadow source. Deferring one frame avoids an iOS race where the main + /// source's reclustering and the spider source's symbol render arrive in + /// different frames, briefly showing originals + spread together. + Future _syncRepeaterSymbols(AppStateProvider appState) async { + if (_mapController == null || + !_styleLoaded || + !_imagesRegistered || + !_clusterLayersReady) { + return; + } + try { + // When repeaters are toggled off, push an empty collection so the pins + // clear without removing/recreating the styled layers (see #347). + final geojson = _showRepeaters + ? _buildRepeaterFeatureCollection(appState) + : _emptyFeatureCollection(); + // Detailed mode references baked per-chip images — make sure every one is + // registered before pushing, or the symbol layer would reference a + // missing icon. No-op in Simplified (features use the pre-registered + // shape images). Driven off the FeatureCollection, so it can't drift. + if (appState.preferences.coverageGridSize == 100) { + await _ensureRepeaterChipImages(geojson); + } + await _mapController!.setGeoJsonSource(_repeaterSourceId, geojson); + } catch (e) { + debugError('[MAP] Failed to update repeater source: $e'); + } + // Also push the spider source. Empty FeatureCollection if no spider open. + final currentZoom = _mapController?.cameraPosition?.zoom ?? _defaultZoom; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _syncSpiderSymbols(appState, currentZoom); + }); + } + + // --------------------------------------------------------------------------- + // Spiderfy helpers — see plan: stacked repeaters fan out around the centroid + // when a tap can't be resolved by zooming further. + // --------------------------------------------------------------------------- + + /// Web-Mercator metres-per-pixel at the given latitude and zoom. + double _metersPerPxAtZoom(double latDeg, num zoom) { + return 156543.03392 * math.cos(latDeg * math.pi / 180) / math.pow(2, zoom); + } + + /// Great-circle distance between two LatLngs in metres (haversine). + /// Used for the spider candidate / connectivity tests — accurate at any + /// latitude, including the poles. + double _haversineMeters(LatLng a, LatLng b) { + const earthRadiusM = 6378137.0; + final lat1 = a.latitude * math.pi / 180; + final lat2 = b.latitude * math.pi / 180; + final dLat = (b.latitude - a.latitude) * math.pi / 180; + final dLon = (b.longitude - a.longitude) * math.pi / 180; + final h = math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(lat1) * + math.cos(lat2) * + math.sin(dLon / 2) * + math.sin(dLon / 2); + return 2 * earthRadiusM * math.asin(math.min(1.0, math.sqrt(h))); + } + + /// MapLibre Native (Android SDK 12.3.1 / iOS 6.19.1, both bound by + /// maplibre_gl 0.25.0) blinks symbol-layer labels for one frame when an + /// `animateCamera` call ends on an exact integer zoom level (15.0, 16.0, + /// 17.0, …). Tracking issue: + /// https://github.com/maplibre/maplibre-native/issues/2477 + /// + /// Workaround until upstream fix: nudge every programmatic zoom value + /// in this file by this epsilon so the camera never settles on an + /// integer. Visually identical (~0.001 zoom is sub-pixel at any scale) + /// but avoids the bug. When it's resolved upstream, set this to 0.0 + /// (or remove the subtractions). + static const double _zoomEpsilon = 0.001; + + /// Maximum zoom the camera can reach (matches `minMaxZoomPreference`). + /// Subtracted by [_zoomEpsilon] to dodge the integer-zoom label-blink + /// bug described above. + static const double _maxUserZoom = 17.0 - _zoomEpsilon; + + /// True when the camera is at (or floating-point close to) the user's + /// hard zoom cap. Spider expansion is gated on this — at any lower zoom + /// taps just zoom in further so the user has a chance to separate the + /// stack visually before falling back to the spider UI. + bool _isAtMaxZoom() { + final z = _mapController?.cameraPosition?.zoom; + if (z == null) return false; + // Small epsilon: zoom can settle at e.g. 16.997 even when the user has + // pinched all the way in; treat that as max. + return z >= _maxUserZoom - 0.05; + } + + /// Find the connected component of repeaters around [anchor] that would + /// still visually overlap at [_maxUserZoom] (the "won't break apart by + /// more zoom" group). + /// + /// Two repeaters are linked when their geographic distance is ≤ the + /// "stick threshold" — `_clusterRadiusPx × metres-per-pixel at the max + /// zoom`. At lat 45° this is ~42 m. Markers within this distance of + /// each other are within the cluster radius even at the user's deepest + /// zoom, so zooming will not separate them visually. + /// + /// BFS seed: repeater closest to [anchor]. Returns at least the seed when + /// any repeater is within the broad search disc; caller should treat a + /// result of length < 2 as "no spiderfy needed". + List _findSpiderGroup(LatLng anchor, AppStateProvider appState) { + final mPerPxMaxZoom = _metersPerPxAtZoom(anchor.latitude, _maxUserZoom); + final stickThresholdM = _clusterRadiusPx * mPerPxMaxZoom; + + // Broad initial radius: 10× the stick threshold so we don't miss an + // indirect CC member that's near another member but far from the anchor. + // Bounded fixed multiplier — keeps the candidate set small even when a + // user taps a continent-scale cluster at low zoom. + final broadRadiusM = stickThresholdM * 10; + final candidates = []; + for (final r in _mapVisibleRepeaters(appState)) { + if (_haversineMeters(anchor, LatLng(r.lat, r.lon)) <= broadRadiusM) { + candidates.add(r); + } + } + if (candidates.isEmpty) return const []; + + // Pick the closest-to-anchor as the BFS seed. + Repeater seed = candidates.first; + var bestD = double.infinity; + for (final r in candidates) { + final d = _haversineMeters(anchor, LatLng(r.lat, r.lon)); + if (d < bestD) { + bestD = d; + seed = r; + } + } + + // Connected component (single-link clustering at stick threshold). + final visited = {seed.id}; + final queue = [seed]; + final result = [seed]; + while (queue.isNotEmpty) { + final cur = queue.removeAt(0); + final curPos = LatLng(cur.lat, cur.lon); + for (final r in candidates) { + if (visited.contains(r.id)) continue; + if (_haversineMeters(curPos, LatLng(r.lat, r.lon)) <= stickThresholdM) { + visited.add(r.id); + result.add(r); + queue.add(r); + } + } + } + return result; + } + + /// Find the [pointCount] repeaters nearest to [anchor], used when expanding + /// a tapped MapLibre cluster bubble. + /// + /// Unlike [_findSpiderGroup] (transitive BFS connected component), this is + /// a non-transitive proximity query — it cannot chain across separate + /// visual clusters. The count comes from Supercluster's `point_count` on + /// the tapped feature, so the result matches the bubble exactly in every + /// realistic case (centroid drift in extreme layouts may shuffle a 1–2 + /// marker boundary, but the spider count is always correct). + List _findSpiderGroupForCluster( + LatLng anchor, int pointCount, AppStateProvider appState) { + if (pointCount <= 0) return const []; + // Bound the candidate set with a generous radius. Supercluster's max + // cluster diameter at maxZoom is ~2× the cluster radius (centroid + // drift); pointCount × stickThreshold is a comfortable upper bound for + // any plausible cluster size. + final mPerPxMaxZoom = _metersPerPxAtZoom(anchor.latitude, _maxUserZoom); + final stickThresholdM = _clusterRadiusPx * mPerPxMaxZoom; + final broadRadiusM = stickThresholdM * math.max(10, pointCount); + final candidates = >[]; + for (final r in _mapVisibleRepeaters(appState)) { + final d = _haversineMeters(anchor, LatLng(r.lat, r.lon)); + if (d <= broadRadiusM) { + candidates.add(MapEntry(r, d)); + } + } + candidates.sort((a, b) => a.value.compareTo(b.value)); + return candidates.take(pointCount).map((e) => e.key).toList(); + } + + /// Layout the [n] spread positions around [center]. Uses a single ring up to + /// 8 markers, two concentric rings up to 20, and a Fermat / golden-angle + /// spiral past 20. + List _computeSpiderRing(LatLng center, int n, double currentZoom) { + final mPerPx = _metersPerPxAtZoom(center.latitude, currentZoom); + final lat0 = center.latitude; + final lon0 = center.longitude; + final cosLat = math.cos(lat0 * math.pi / 180); + + // Convert (dx_px, dy_px) — screen-space offset from centre — back to a + // geographic LatLng. Screen y grows downward; flip dy so positive screen-y + // maps to a southward (lower-latitude) offset. + LatLng offset(double dxPx, double dyPx) { + final dxM = dxPx * mPerPx; + final dyM = dyPx * mPerPx; + final dLat = -dyM / 111320; + final dLon = dxM / (111320 * cosLat); + return LatLng(lat0 + dLat, lon0 + dLon); + } + + final positions = []; + if (n <= 8) { + // Single ring at the inner radius, evenly spaced from top (-π/2). + for (var i = 0; i < n; i++) { + final angle = -math.pi / 2 + 2 * math.pi * i / n; + positions.add(offset( + _spiderInnerRadiusPx * math.cos(angle), + _spiderInnerRadiusPx * math.sin(angle), + )); + } + } else if (n <= 20) { + // Two concentric rings: 7 inner + (n−7) outer with a half-angle stagger + // so outer markers don't sit directly behind inner ones. + const innerCount = 7; + final outerCount = n - innerCount; + for (var i = 0; i < innerCount; i++) { + final angle = -math.pi / 2 + 2 * math.pi * i / innerCount; + positions.add(offset( + _spiderInnerRadiusPx * math.cos(angle), + _spiderInnerRadiusPx * math.sin(angle), + )); + } + for (var i = 0; i < outerCount; i++) { + final angle = + -math.pi / 2 + math.pi / outerCount + 2 * math.pi * i / outerCount; + positions.add(offset( + _spiderOuterRadiusPx * math.cos(angle), + _spiderOuterRadiusPx * math.sin(angle), + )); + } + } else { + // Golden-angle spiral — markers self-space without overlap. + const goldenAngle = 137.508 * math.pi / 180; + for (var i = 0; i < n; i++) { + final angle = i * goldenAngle - math.pi / 2; + final r = 30 + 9 * math.sqrt(i + 1); + positions.add(offset( + r * math.cos(angle), + r * math.sin(angle), + )); + } + } + return positions; + } + + /// Build the spider shadow source's FeatureCollection — Point features for + /// every spread marker plus LineString features for the leader lines. + /// Returns an empty collection when no spider is open. + Map _buildSpiderFeatureCollection( + AppStateProvider appState, double currentZoom) { + if (_spiderCenter == null || _spiderRepeaters.isEmpty) { + return { + 'type': 'FeatureCollection', + 'features': >[] + }; + } + final center = _spiderCenter!; + final positions = + _computeSpiderRing(center, _spiderRepeaters.length, currentZoom); + final mPerPx = _metersPerPxAtZoom(center.latitude, currentZoom); + final cosLat = math.cos(center.latitude * math.pi / 180); + + final duplicates = _getDuplicateRepeaterIds(_mapVisibleRepeaters(appState)); + final hopOverride = + appState.enforceHopBytes ? appState.effectiveHopBytes : null; + // Match the individual layer: Detailed (gsize 100) bakes the hex into the + // chip image (the spider layer reuses the same no-text-field props, so a + // generic shape would render as an empty box); Simplified uses the shared + // shape + text-field hex. See _buildRepeaterFeatureCollection. + final detailed = appState.preferences.coverageGridSize == 100; + + final features = >[]; + for (var i = 0; i < _spiderRepeaters.length; i++) { + final repeater = _spiderRepeaters[i]; + final pos = positions[i]; + + final isDuplicate = duplicates.contains(repeater.id); + final statusKey = _repeaterStatusKey(repeater, isDuplicate); + final effectiveBytes = hopOverride ?? repeater.hopBytes; + final shapeBytes = effectiveBytes >= 3 + ? 3 + : effectiveBytes == 2 + ? 2 + : 1; + final hex = repeater.displayHexId(overrideHopBytes: hopOverride); + final iconImage = detailed + ? _MapImages.repeaterChip(statusKey, shapeBytes, hex) + : _MapImages.repeater(statusKey, shapeBytes); + final colorHex = _colorToHex(_repeaterStatusColor(statusKey)); + + features.add({ + 'type': 'Feature', + 'id': repeater.id, + 'properties': { + 'repeaterId': repeater.id, + 'iconImage': iconImage, + 'color': colorHex, + 'hex': hex, + 'isDuplicate': isDuplicate, + if (hopOverride != null) 'hopOverride': hopOverride, + }, + 'geometry': { + 'type': 'Point', + 'coordinates': [pos.longitude, pos.latitude], + }, + }); + + // Leader line — shortened by `_leaderLineEndShortenPx` at the marker + // end so it doesn't punch through the icon / label halo. Compute the + // shortening in screen-pixel space, then convert back to lat/lon. + final dxM = (pos.longitude - center.longitude) * 111320 * cosLat; + final dyM = (pos.latitude - center.latitude) * 111320; + final dxPx = dxM / mPerPx; + final dyPx = dyM / mPerPx; + final lenPx = math.sqrt(dxPx * dxPx + dyPx * dyPx); + if (lenPx <= _leaderLineEndShortenPx) continue; + final scale = (lenPx - _leaderLineEndShortenPx) / lenPx; + final endLon = + center.longitude + (pos.longitude - center.longitude) * scale; + final endLat = center.latitude + (pos.latitude - center.latitude) * scale; + features.add({ + 'type': 'Feature', + 'properties': {'repeaterId': repeater.id}, + 'geometry': { + 'type': 'LineString', + 'coordinates': [ + [center.longitude, center.latitude], + [endLon, endLat], + ], + }, + }); + } + + return {'type': 'FeatureCollection', 'features': features}; + } + + /// Push the current spider state to the spider GeoJSON source. Called from + /// `_syncRepeaterSymbols` (post-frame) and after spider state mutations. + Future _syncSpiderSymbols( + AppStateProvider appState, double currentZoom) async { + if (_mapController == null || !_clusterLayersReady) return; + try { + final geojson = _buildSpiderFeatureCollection(appState, currentZoom); + // Detailed mode references baked per-chip images. The main collection + // usually registers them first, but a spidered repeater that the main + // builder filtered out (focus / isolation / heard-repeater fade) would + // reference an unregistered chip — bake any missing ones here too. + if (appState.preferences.coverageGridSize == 100) { + await _ensureRepeaterChipImages(geojson); + } + await _mapController!.setGeoJsonSource(_spiderSourceId, geojson); + } catch (e) { + debugError('[MAP] Failed to update spider source: $e'); + } + } + + /// Open the spider — fan out [repeaters] around [center]. No-op for groups + /// of fewer than 2 (caller should fall through to detail-sheet/zoom paths). + void _spiderfy(LatLng center, List repeaters) { + if (repeaters.length < 2 || _mapController == null || !mounted) return; + setState(() { + _spiderCenter = center; + _spiderRepeaters = List.unmodifiable(repeaters); + _spiderOpenedAtZoom = _mapController!.cameraPosition?.zoom; + }); + debugLog( + '[MAP] Spider opened with ${repeaters.length} markers at ${center.latitude.toStringAsFixed(5)},${center.longitude.toStringAsFixed(5)}'); + // Resync the main source (so the inSpider tag hides originals on the + // individual layer) and the spider source (post-frame inside the sync). + _syncRepeaterSymbols(context.read()); + } + + /// Close the spider — clears state and resyncs both sources. + void _collapseSpider() { + if (_spiderCenter == null || !mounted) return; + setState(() { + _spiderCenter = null; + _spiderRepeaters = const []; + _spiderOpenedAtZoom = null; + }); + debugLog('[MAP] Spider collapsed'); + _syncRepeaterSymbols(context.read()); + } + + /// Composite key for a coverage marker symbol — kind + timestamp ms + lat/lon. + /// Used as the map key in [_coverageSymbols] and to detect updates/removals. + /// Lat/lon at 5-decimal precision (~1.1m) is included so two distinct pings + /// that happen to land in the same millisecond (possible under heavy RX + /// traffic) don't collide on a shared key. + String _coverageKey(String type, DateTime ts, double lat, double lon) => + '${type}_${ts.millisecondsSinceEpoch}_' + '${lat.toStringAsFixed(5)}_${lon.toStringAsFixed(5)}'; + + /// Render/tap z-order for a coverage marker: a monotonic counter assigned + /// once per [_coverageKey], so a newer symbol always renders on top of (and + /// wins the topmost-first tap hit-test over) an older overlapping one. + /// + /// The annotation manager's symbol layer uses `symbol-sort-key: ["get", + /// "zIndex"]`; with `icon-allow-overlap = true` a higher sort key overlaps a + /// lower one (and flips `symbol-z-order: auto` off its `viewport-y` default). + /// + /// Assign-once → an existing symbol's key never changes value, so the + /// incremental-sync skip path never needs to re-push it. The counter stays + /// well under 2^24, the exact-integer limit of the native float32 sort-key — + /// the previous timestamp-in-seconds key had grown to ~46M, where float32 + /// quantizes to multiples of 4, so pings within ~4s collided on one sort key + /// and stacked in undefined order (the RX burst bug this replaces). + /// + /// NOTE: within a single sync, brand-new keys are numbered in iteration order + /// (TX→RX→DISC→Trace, each oldest→newest); cross-type ties in the same ~250ms + /// window are cosmetic (different icons) and intentionally not pre-sorted. + int _zIndexFor(String key) => + _coverageZIndex.putIfAbsent(key, () => ++_coverageZCounter); + + /// Diff-syncs native coverage symbols (TX/RX/DISC/Trace) against app state. + /// One symbol per ping, image varies by type/success state, opacity reflects + /// focus mode (faded if focus active and this isn't the focused ping). + /// + /// Marker style preference changes are NOT handled here — when the user + /// switches between circle/pin/diamond/dot, the caller must first call + /// [_handleMarkerStyleChange] to re-register the bitmap variants. + Future _syncCoverageSymbols(AppStateProvider appState) async { + if (_mapController == null || !_styleLoaded || !_imagesRegistered) return; + + // Re-register coverage images if the user changed their style preference + final currentStyle = appState.preferences.markerStyle; + if (_registeredCoverageStyle != currentStyle) { + await _registerCoverageImages(currentStyle); + // After re-registering, all existing coverage symbols still reference + // the same image names — but the underlying bitmaps have changed shape. + // The native side picks up the new bitmaps automatically. No need to + // update each symbol. + } + + final wantedKeys = {}; + final focusActive = _focusedPingLocation != null; + // Diagnostics so the O(n)-per-event regression stays measurable on-device: + // a healthy steady-state sync should be mostly "unchanged". + int added = 0, updated = 0, skipped = 0; + + Future syncOne({ + required String type, + required double lat, + required double lon, + required DateTime ts, + required bool success, + required int idForMetadata, + String? iconImageOverride, + }) async { + final key = _coverageKey(type, ts, lat, lon); + final isFocused = _isFocusedPing(lat, lon, ts); + // In focus mode, hide every coverage marker except the focused ping. + // Skipping wantedKeys lets the cleanup loop remove them entirely so the + // map is uncluttered. Dismissing focus re-syncs and restores them. + if (focusActive && !isFocused) return; + wantedKeys.add(key); + + final iconImage = iconImageOverride ?? _MapImages.coverage(type, success); + final iconSize = isFocused ? 1.2 : 1.0; + // Everything that can change for an existing symbol (geometry is fixed by + // the key). If unchanged, skip the native round-trip entirely. + final sig = '$iconImage|$iconSize'; + + final existing = _coverageSymbols[key]; + if (existing == null) { + try { + final symbol = await _mapController!.addSymbol( + SymbolOptions( + geometry: LatLng(lat, lon), + iconImage: iconImage, + iconSize: iconSize, + // Recency-ordered sort key: the most recent ping renders on top + // and wins the native topmost-first tap hit-test, so an older + // overlapping marker is never selected in its place. + zIndex: _zIndexFor(key), + ), + {'kind': type, 'id': idForMetadata}, + ); + _coverageSymbols[key] = symbol; + _coverageSymbolSig[key] = sig; + added++; + } catch (e) { + debugError('[MAP] addSymbol($type) failed at $ts: $e'); + } + } else if (_coverageSymbolSig[key] != sig) { + try { + await _mapController!.updateSymbol( + existing, + SymbolOptions( + geometry: LatLng(lat, lon), + iconImage: iconImage, + iconSize: iconSize, + ), + ); + _coverageSymbolSig[key] = sig; + updated++; + } catch (e) { + debugError('[MAP] updateSymbol($type) failed at $ts: $e'); + } + } else { + skipped++; + } + } + + // When viewing a history session, show only those markers + if (appState.viewingHistorySession && + appState.historySessionMarkers != null) { + for (final marker in appState.historySessionMarkers!) { + if (marker.latitude == null || marker.longitude == null) continue; + final mapping = _historyMarkerType(marker.type); + await syncOne( + type: 'history_${mapping.type}', + lat: marker.latitude!, + lon: marker.longitude!, + ts: marker.timestamp, + success: mapping.success, + idForMetadata: marker.timestamp.millisecondsSinceEpoch, + iconImageOverride: _MapImages.coverage(mapping.type, mapping.success), + ); + } + } else { + // TX pings + for (final ping in appState.txPings) { + final hasDirectEcho = + ping.heardRepeaters.any((r) => r.pathHops == null); + final hasMultiHopOnly = + !hasDirectEcho && ping.heardRepeaters.isNotEmpty; + await syncOne( + type: 'tx', + lat: ping.latitude, + lon: ping.longitude, + ts: ping.timestamp, + success: ping.heardRepeaters.isNotEmpty, + idForMetadata: ping.timestamp.millisecondsSinceEpoch, + iconImageOverride: + hasMultiHopOnly ? _MapImages.coverage('rx', true) : null, + ); + } + + // RX pings + for (final ping in appState.rxPings) { + await syncOne( + type: 'rx', + lat: ping.latitude, + lon: ping.longitude, + ts: ping.timestamp, + success: true, + idForMetadata: ping.timestamp.millisecondsSinceEpoch, + ); + } + + // DISC entries (success = received node responses; drop = treat as TX fail) + for (final entry in appState.discLogEntries) { + final received = entry.nodeCount > 0; + final renderAsTxFail = !received && appState.discDropEnabled; + await syncOne( + type: 'disc', + lat: entry.latitude, + lon: entry.longitude, + ts: entry.timestamp, + success: received, + idForMetadata: entry.timestamp.millisecondsSinceEpoch, + iconImageOverride: + renderAsTxFail ? _MapImages.coverage('tx', false) : null, + ); + } + + // Trace entries + for (final entry in appState.traceLogEntries) { + await syncOne( + type: 'trace', + lat: entry.latitude, + lon: entry.longitude, + ts: entry.timestamp, + success: entry.success, + idForMetadata: entry.timestamp.millisecondsSinceEpoch, + ); + } + } + + // Remove symbols for pings that no longer exist (e.g., user cleared markers) + final toRemove = + _coverageSymbols.keys.where((k) => !wantedKeys.contains(k)).toList(); + for (final key in toRemove) { + final sym = _coverageSymbols.remove(key); + _coverageSymbolSig.remove(key); + _coverageZIndex.remove(key); + if (sym != null) { + try { + await _mapController!.removeSymbol(sym); + } catch (_) {} + } + } + + // Only log when work actually happened — a healthy sync over a large + // session should read "+0 added, 0 updated, N unchanged" (no native churn). + if (added > 0 || updated > 0 || toRemove.isNotEmpty) { + debugLog('[MAP] Coverage sync: +$added added, $updated updated, ' + '$skipped unchanged, ${toRemove.length} removed'); + } + } + + /// Returns true if the given GPS marker style should rotate to face the + /// user's heading (vs staying screen-aligned). Arrow/walk/pacman face the + /// heading; car/bike/boat icons stay upright on a rotated map. + bool _gpsStyleFacesHeading(String style) => + style == 'arrow' || style == 'walk' || style == 'chomper'; + + /// Computes the iconRotate value for the GPS marker. + /// + /// MapLibre annotation symbols use the default `icon-rotation-alignment: auto` + /// which resolves to `viewport` for point symbols — meaning iconRotate is + /// applied in screen space, not map space. That has two consequences: + /// + /// - Rotating styles (arrow/walk/chomper) must point in the direction of + /// travel both in always-north mode (where bearing = 0, so iconRotate + /// = heading) AND in heading mode (where the map is rotated so that + /// direction-of-travel is screen-up — so iconRotate should be 0). + /// The single formula that works for both is `heading - bearing`. + /// + /// - Non-rotating styles (car/bike/boat) should always be drawn upright + /// on screen. With viewport alignment that's iconRotate = 0 regardless + /// of bearing; the icon is already screen-aligned by default. + double _gpsIconRotate(String style, double heading) { + final bearing = _mapController?.cameraPosition?.bearing ?? 0; + if (_gpsStyleFacesHeading(style)) { + final rotated = heading - bearing; + // Normalize to 0..360 so MapLibre doesn't take the "long way around" + // when iconRotate crosses the ±180° seam during interpolation. + return (rotated % 360 + 360) % 360; + } + return 0; + } + + /// Pushes the single GPS position feature into the dedicated puck source to + /// match [appState.currentPosition]. Called from the post-frame sync trigger. + /// The puck lives in its own top-most layer (see [_ensureGpsPuckLayer]), so + /// this only ever rewrites a one-feature source — it never touches the shared + /// coverage-pin source, which is what eliminated the blink. + Future _syncGpsSymbol(AppStateProvider appState) async { + if (_mapController == null || !_styleLoaded || !_imagesRegistered) return; + + // Ensure the dedicated puck source+layer exists (idempotent). On style + // reload _gpsPuckLayerInstalled is reset so this recreates it topmost. + if (!await _ensureGpsPuckLayer()) return; + + final pos = appState.currentPosition; + if (pos == null) { + // No GPS lock — clear the puck by pushing an empty collection. Keeps the + // styled layer in place (no remove/recreate). + try { + await _mapController! + .setGeoJsonSource(_gpsPuckSourceId, _emptyFeatureCollection()); + _recordGpsSyncVersion(appState, null); + } catch (e) { + debugError('[MAP] clear gps puck failed: $e'); + } + return; + } + + final style = appState.preferences.gpsMarkerStyle; + // Use the derived heading (updated by _computeHeading in build()) so the + // arrow/walk/chomper markers actually point in the direction of travel + // even when pos.heading is stale or unset. + final iconRotate = _gpsIconRotate(style, _computedHeading ?? 0); + + try { + await _mapController!.setGeoJsonSource( + _gpsPuckSourceId, + _buildGpsPuckFeatureCollection( + pos.latitude, pos.longitude, style, iconRotate), + ); + _recordGpsSyncVersion(appState, pos); + } catch (e) { + debugError('[MAP] update gps puck failed: $e'); + } + } + + /// Mark the puck as in sync with [pos] (null = cleared). Only called once a + /// push has actually landed; a bailed or failed sync leaves the version + /// unrecorded so the next provider notify retries it (#482). + void _recordGpsSyncVersion(AppStateProvider appState, Position? pos) { + _lastGpsSyncVersion = Object.hash( + pos?.latitude, + pos?.longitude, + _computedHeading, + appState.preferences.gpsMarkerStyle, + ); + } + + /// One-Point FeatureCollection for the GPS puck source. `iconImage` and + /// `iconRotate` are read by the puck symbol layer's data-driven expressions. + Map _buildGpsPuckFeatureCollection( + double lat, double lon, String style, double iconRotate) { + return { + 'type': 'FeatureCollection', + 'features': [ + { + 'type': 'Feature', + 'properties': { + 'iconImage': _MapImages.gps(style), + 'iconRotate': iconRotate, + }, + 'geometry': { + 'type': 'Point', + // GeoJSON convention: [longitude, latitude] + 'coordinates': [lon, lat], + }, + }, + ], + }; + } + + /// Idempotently (re)create the dedicated GPS-puck source + symbol layer. + /// The layer is added with NO belowLayerId so it sits on TOP of every other + /// layer (coverage fills/pins, repeaters) — the puck is always-on-top by layer + /// order, with no symbol-sort-key contention. enableInteraction:false so taps + /// pass through to the repeaters/clusters underneath. Mirrors + /// [_ensureCoverageLinesLayer]. + /// Gated entry point; see [_coverageGate]. Skips the gate entirely once + /// installed so a slow coverage mutation cannot stall the per-tick GPS sync. + Future _ensureGpsPuckLayer() => _gpsPuckLayerInstalled + ? Future.value(true) + : _coverageGate.run(_ensureGpsPuckLayerLocked); + + Future _ensureGpsPuckLayerLocked() async { + if (_gpsPuckLayerInstalled) return true; + if (_mapController == null) return false; + try { + try { + await _mapController!.removeLayer(_gpsPuckLayerId); + } catch (_) {} + try { + await _mapController!.removeSource(_gpsPuckSourceId); + } catch (_) {} + // "Already exists" on either add means a previous install's native + // object survived a silently-failed teardown. Adopt it PER OBJECT: the + // layer's paint properties are constant data-driven expressions, so an + // existing object is identical to the one the add would have created, + // and the caller's setGeoJsonSource repositions it. Reporting failure + // here left the installed-flag false for the life of the map session + // while the stale layer kept rendering the last pushed position (#482). + // The adopt must not cover both adds at once: removeLayer succeeding + // while removeSource fails would otherwise adopt the surviving source + // and never re-add the layer, leaving no arrow at all. + try { + await _mapController! + .addGeoJsonSource(_gpsPuckSourceId, _emptyFeatureCollection()); + } catch (e) { + if (!mapStyleObjectAlreadyExists(e)) rethrow; + debugWarn('[MAP] gps-puck source already present, adopting it: $e'); + } + try { + await _mapController!.addSymbolLayer( + _gpsPuckSourceId, + _gpsPuckLayerId, + const SymbolLayerProperties( + iconImage: ['get', 'iconImage'], + iconRotate: ['get', 'iconRotate'], + iconAllowOverlap: true, + iconIgnorePlacement: true, + ), + // No belowLayerId => topmost, above all coverage pings and repeaters. + enableInteraction: false, + ); + } catch (e) { + if (!mapStyleObjectAlreadyExists(e)) rethrow; + debugWarn('[MAP] gps-puck layer already present, adopting it: $e'); + } + _gpsPuckLayerInstalled = true; + return true; + } catch (e) { + debugError('[MAP] gps-puck layer create failed: $e'); + return false; + } + } + + /// Updates only the GPS symbol's iconRotate. Called from the camera-change + /// listener when the bearing changes — under viewport alignment, rotating + /// styles (arrow/walk/chomper) are the ones whose iconRotate depends on the + /// bearing (iconRotate = heading - bearing), so they need refreshing as the + /// bearing animates. Non-rotating styles use iconRotate = 0 and don't care. + /// Cheaper than calling [_syncGpsSymbol] which also updates position. + Future _updateGpsSymbolRotation() async { + if (!_gpsPuckLayerInstalled || _mapController == null) return; + final appState = context.read(); + final pos = appState.currentPosition; + if (pos == null) return; + final style = appState.preferences.gpsMarkerStyle; + if (!_gpsStyleFacesHeading(style)) return; + try { + await _mapController!.setGeoJsonSource( + _gpsPuckSourceId, + _buildGpsPuckFeatureCollection(pos.latitude, pos.longitude, style, + _gpsIconRotate(style, _computedHeading ?? 0)), + ); + } catch (_) {} + } + + // Source/layer ID constants for the focus-mode dotted lines + static const _focusLinesSourceId = 'focus-lines-source'; + static const _focusLinesLayerId = 'focus-lines-layer'; + static const _focusLinesAmbiguousLayerId = 'focus-lines-ambiguous-border'; + static const _focusLinesAmbiguousLabelId = 'focus-lines-ambiguous-label'; + + // Source/layer IDs for the community coverage connection lines (shared by the + // tile fan-out and the repeater coverage view — mutually exclusive in time, + // per-feature `color`) and the repeater coverage cells (per-feature fill). + static const _coverageLinesSourceId = 'coverage-lines-source'; + static const _coverageLinesLayerId = 'coverage-lines-layer'; + static const _coverageCellsSourceId = 'coverage-cells-source'; + static const _coverageCellsLayerId = 'coverage-cells-layer'; + + /// Builds and applies the focus-mode dotted polylines that visually connect + /// a focused ping to each repeater that heard it. Color-coded by SNR; + /// ambiguous matches get a wider white outline drawn underneath. + /// + /// Implementation uses a GeoJSON source + line layer (rather than the + /// annotation-level addLine API) because LineOptions does not expose + /// `lineDasharray`, but LineLayerProperties does. + /// + /// Idempotent: removes any existing source/layers first, then re-adds with + /// the latest focus state. + Future _updateFocusLines() async { + if (_mapController == null || !_styleLoaded) return; + + final hasFocus = + _focusedPingLocation != null && _focusedRepeaters.isNotEmpty; + + // No focus → no install. Skip the platform calls entirely when there's + // nothing on the map to begin with; only run the remove block when a + // previous activation actually installed the layers. + if (!hasFocus) { + if (!_focusLinesInstalled) return; + try { + await _mapController!.removeLayer(_focusLinesLayerId); + } catch (_) {} + try { + await _mapController!.removeLayer(_focusLinesAmbiguousLabelId); + } catch (_) {} + try { + await _mapController!.removeLayer(_focusLinesAmbiguousLayerId); + } catch (_) {} + try { + await _mapController!.removeSource(_focusLinesSourceId); + } catch (_) {} + _focusLinesInstalled = false; + return; + } + + // Focus is active — remove any prior install before re-adding with the + // current focus state. Order matters: layers BEFORE their source. + if (_focusLinesInstalled) { + try { + await _mapController!.removeLayer(_focusLinesLayerId); + } catch (_) {} + try { + await _mapController!.removeLayer(_focusLinesAmbiguousLabelId); + } catch (_) {} + try { + await _mapController!.removeLayer(_focusLinesAmbiguousLayerId); + } catch (_) {} + try { + await _mapController!.removeSource(_focusLinesSourceId); + } catch (_) {} + _focusLinesInstalled = false; + } + + // Build a FeatureCollection with one LineString per connected repeater. + // Per-feature properties carry the line color (data-driven styling) and + // ambiguous flag (used as a layer filter for the border line). + final features = >[]; + for (final r in _focusedRepeaters) { + final color = r.snr != null ? PingColors.snrColor(r.snr!) : Colors.grey; + features.add({ + 'type': 'Feature', + 'properties': { + 'color': _colorToHex(color), + 'ambiguous': r.ambiguous, + }, + 'geometry': { + 'type': 'LineString', + 'coordinates': [ + [_focusedPingLocation!.longitude, _focusedPingLocation!.latitude], + [r.repeater.lon, r.repeater.lat], + ], + }, + }); + } + + // Pass the FeatureCollection as a Dart Map (NOT a jsonEncode-d string). + // The iOS plugin's buildShapeSource crashes if `data` is a string that's + // not a URL — see fix in _setupRepeaterClusterLayers for the same gotcha. + final geojson = { + 'type': 'FeatureCollection', + 'features': features, + }; + + try { + await _mapController!.addSource( + _focusLinesSourceId, + GeojsonSourceProperties(data: geojson), + ); + + // Insert focus line layers BELOW the individual repeater layer so + // repeater boxes (and the cluster bubbles/count text above them, plus + // the symbol annotation markers on top of those) all render on top of + // the connecting lines. This is especially important at the repeater + // end of each line, where the dotted stroke would otherwise draw over + // the repeater box. + const belowLayer = _repeaterIndividualLayerId; + + // Border line (amber, wider, only for ambiguous matches) — added FIRST + // so it renders BENEATH the colored line on top. + await _mapController!.addLineLayer( + _focusLinesSourceId, + _focusLinesAmbiguousLayerId, + const LineLayerProperties( + lineColor: '#F59E0B', + lineOpacity: 0.8, + lineWidth: 6.5, + lineDasharray: [2, 4], + lineCap: 'round', + ), + filter: [ + '==', + ['get', 'ambiguous'], + true + ], + belowLayerId: belowLayer, + ); + + // "DUPLICATE ID" text along ambiguous lines — only renders when the + // line is long enough for the label to fit without collision. + await _mapController!.addSymbolLayer( + _focusLinesSourceId, + _focusLinesAmbiguousLabelId, + const SymbolLayerProperties( + symbolPlacement: 'line', + textField: 'DUP', + textSize: 11, + textColor: '#F59E0B', + textHaloColor: '#FFFFFF', + textHaloWidth: 1.5, + textKeepUpright: true, + textFont: _defaultFontStack, + symbolSpacing: 200, + textAllowOverlap: false, + textOptional: true, + ), + filter: [ + '==', + ['get', 'ambiguous'], + true + ], + belowLayerId: belowLayer, + ); + + // Main colored line (color from feature property via data-driven expression) + await _mapController!.addLineLayer( + _focusLinesSourceId, + _focusLinesLayerId, + const LineLayerProperties( + lineColor: ['get', 'color'], + lineOpacity: 0.9, + lineWidth: 3.5, + lineDasharray: [2, 4], + lineCap: 'round', + ), + belowLayerId: belowLayer, + ); + _focusLinesInstalled = true; + } catch (e) { + debugError('[MAP] Failed to add focus lines: $e'); + } + } + + // =========================================================================== + // Community coverage connection lines + cells (Features A & B) + // =========================================================================== + + /// Volume cap shared by both coverage features (user choice: keep the + /// FARTHEST). When [items] exceeds [cap], sorts by [dist] DESCENDING and keeps + /// the first [cap] (so the longest-reach lines/cells always survive); logs the + /// truncation. Returns [items] unchanged when within the cap. + List _capByFarthest( + List items, double Function(T) dist, int cap, String logTag) { + if (items.length <= cap) return items; + final sorted = [...items]..sort((a, b) => dist(b).compareTo(dist(a))); + debugLog('$logTag truncated ${items.length}->$cap (kept farthest)'); + return sorted.take(cap).toList(); + } + + /// Idempotently (re)create the empty coverage connection-line layer + /// (data-driven dashed lines, below the repeater chips). Modeled on + /// [_ensureCellHighlightLayer]. + Future _ensureCoverageLinesLayer() async { + if (_coverageLinesInstalled) return true; + if (_mapController == null) return false; + try { + final belowLayer = _clusterLayersReady + ? _repeaterIndividualLayerId + : _symbolAnnotationLayerId(); + try { + await _mapController!.removeLayer(_coverageLinesLayerId); + } catch (_) {} + try { + await _mapController!.removeSource(_coverageLinesSourceId); + } catch (_) {} + await _mapController! + .addGeoJsonSource(_coverageLinesSourceId, _emptyFeatureCollection()); + await _mapController!.addLineLayer( + _coverageLinesSourceId, + _coverageLinesLayerId, + const LineLayerProperties( + lineColor: ['get', 'color'], + lineOpacity: 0.9, + // Per-feature width: the tile fan-out (Feature A) and the repeater + // coverage (Feature B) share this layer but use different widths. + lineWidth: ['get', 'width'], + lineDasharray: [2, 4], + lineCap: 'round', + ), + belowLayerId: belowLayer, + ); + _coverageLinesInstalled = true; + return true; + } catch (e) { + debugLog('[COVERAGE] coverage-lines layer create failed: $e'); + return false; + } + } + + /// Idempotently (re)create the empty coverage cells fill layer (Feature B), + /// anchored BELOW the lines layer so the lines read on top of the cells. + Future _ensureCoverageCellsLayer() async { + if (_coverageCellsInstalled) return true; + if (_mapController == null) return false; + try { + final belowLayer = _coverageLinesInstalled + ? _coverageLinesLayerId + : (_clusterLayersReady + ? _repeaterIndividualLayerId + : _symbolAnnotationLayerId()); + try { + await _mapController!.removeLayer(_coverageCellsLayerId); + } catch (_) {} + try { + await _mapController!.removeSource(_coverageCellsSourceId); + } catch (_) {} + await _mapController! + .addGeoJsonSource(_coverageCellsSourceId, _emptyFeatureCollection()); + await _mapController!.addFillLayer( + _coverageCellsSourceId, + _coverageCellsLayerId, + const FillLayerProperties( + fillColor: ['get', 'fill'], + fillOutlineColor: ['get', 'border'], + fillOpacity: 0.8, + ), + belowLayerId: belowLayer, + ); + _coverageCellsInstalled = true; + return true; + } catch (e) { + debugLog('[COVERAGE] coverage-cells layer create failed: $e'); + return false; + } + } + + /// Draw one dashed connection line per [segments] entry from + /// ([startLat],[startLon]) out to each segment's endpoint, coloured by the + /// segment's `color` (blue for the tile fan-out, status colour for a repeater). + /// Empty list clears the layer. + Future _updateCoverageLines( + List<({double lat, double lon, String color})> segments, + double startLat, + double startLon, + {double width = 2.5}) async { + if (_mapController == null || !_styleLoaded) return; + if (segments.isEmpty) { + await _clearCoverageLines(); + return; + } + if (!await _ensureCoverageLinesLayer()) return; + final features = >[ + for (final s in segments) + { + 'type': 'Feature', + 'properties': {'color': s.color, 'width': width}, + 'geometry': { + 'type': 'LineString', + 'coordinates': [ + [startLon, startLat], + [s.lon, s.lat], + ], + }, + }, + ]; + try { + await _mapController!.setGeoJsonSource(_coverageLinesSourceId, + {'type': 'FeatureCollection', 'features': features}); + } catch (e) { + debugLog('[COVERAGE] coverage-lines set failed: $e'); + } + } + + /// Empty the coverage-lines source so it renders nothing (layer stays). + Future _clearCoverageLines() async { + if (_mapController == null || !_coverageLinesInstalled) return; + try { + await _mapController! + .setGeoJsonSource(_coverageLinesSourceId, _emptyFeatureCollection()); + } catch (e) { + debugLog('[COVERAGE] coverage-lines clear failed: $e'); + } + } + + /// Draw the selected repeater's coverage [cells] as status-coloured fills + /// (Feature B). [latStep]/[lonStep] size each cell's ring. Empty list clears. + Future _updateCoverageCells(List cells, + String cvd, double latStep, double lonStep) async { + if (_mapController == null || !_styleLoaded) return; + if (cells.isEmpty) { + await _clearCoverageCells(); + return; + } + if (!await _ensureCoverageCellsLayer()) return; + final features = >[]; + for (final c in cells) { + final colors = CoverageTilePalette.colorsForStatus(cvd, c.st); + final ring = GridCell(c.li, c.lj, latStep, lonStep).blockRing(0); + features.add({ + 'type': 'Feature', + 'properties': {'fill': colors[0], 'border': colors[1]}, + 'geometry': { + 'type': 'Polygon', + 'coordinates': [ring], + }, + }); + } + try { + await _mapController!.setGeoJsonSource(_coverageCellsSourceId, + {'type': 'FeatureCollection', 'features': features}); + } catch (e) { + debugLog('[COVERAGE] coverage-cells set failed: $e'); + } + } + + /// Empty the coverage-cells source so it renders nothing (layer stays). + Future _clearCoverageCells() async { + if (_mapController == null || !_coverageCellsInstalled) return; + try { + await _mapController! + .setGeoJsonSource(_coverageCellsSourceId, _emptyFeatureCollection()); + } catch (e) { + debugLog('[COVERAGE] coverage-cells clear failed: $e'); + } + } + + /// Place a distance-pill label at the midpoint of each coverage fan-out line + /// (Feature A). Mirrors [_syncDistanceLabels] but with its own tracking keyed + /// by endpoint lat/lon (6dp). Empty list removes all coverage labels. + /// Screen-space rotation (degrees, clockwise) so a horizontal distance label + /// runs ALONG the line from ([startLat],[startLon]) to ([endLat],[endLon]) + /// instead of staying flat. Annotation symbols rotate in viewport space, so + /// this is the line's on-screen angle — the geographic bearing minus the + /// camera bearing, minus 90° (a horizontal label already lies along an + /// east-west line) — flipped 180° when it would otherwise read upside-down. + double _lineLabelRotation( + double startLat, double startLon, double endLat, double endLon) { + final bearing = + Geolocator.bearingBetween(startLat, startLon, endLat, endLon); + final cam = _mapController?.cameraPosition?.bearing ?? 0; + var angle = bearing - cam - 90; + angle = (angle + 180) % 360 - 180; // normalize to [-180, 180) + if (angle > 90) angle -= 180; // keep upright (text never reads inverted) + if (angle < -90) angle += 180; + return angle; + } + + Future _syncCoverageDistanceLabels( + List<({double lat, double lon, String color})> segments, + double startLat, + double startLon, + bool isImperial) async { + if (_mapController == null || !_styleLoaded) return; + if (segments.isEmpty) { + await _clearCoverageDistanceLabels(); + return; + } + final wanted = {}; + for (final s in segments) { + final key = '${s.lat.toStringAsFixed(6)},${s.lon.toStringAsFixed(6)}'; + wanted.add(key); + final midLat = (startLat + s.lat) / 2; + final midLon = (startLon + s.lon) / 2; + final meters = + GpsService.distanceBetween(startLat, startLon, s.lat, s.lon); + final labelText = meters < 1000 + ? formatMeters(meters, isImperial: isImperial) + : formatKilometers(meters / 1000, isImperial: isImperial); + final imageName = 'cov-dist-${labelText.hashCode}'; + if (!_registeredCoverageLabelImages.contains(imageName)) { + try { + final rendered = await _renderDistanceLabelPng(labelText); + await _mapController!.addImage(imageName, rendered.bytes); + _registeredCoverageLabelImages.add(imageName); + _registeredCoverageLabelImageSizes[imageName] = rendered.size; + } catch (e) { + debugError('[MAP] render/addImage(coverage label) failed: $e'); + } + } + final options = SymbolOptions( + geometry: LatLng(midLat, midLon), + iconImage: imageName, + iconSize: 1.0, + iconAnchor: 'center', + // Rotate the pill to follow its line (kept upright) instead of staying + // flat across the fan-out. + iconRotate: _lineLabelRotation(startLat, startLon, s.lat, s.lon), + ); + final existing = _coverageDistanceLabelSymbols[key]; + if (existing == null) { + try { + _coverageDistanceLabelSymbols[key] = await _mapController! + .addSymbol(options, {'kind': 'cov-distance'}); + } catch (e) { + debugError('[MAP] addSymbol(coverage distance) failed: $e'); + } + } else { + try { + await _mapController!.updateSymbol(existing, options); + } catch (e) { + debugError('[MAP] updateSymbol(coverage distance) failed: $e'); + } + } + } + final toRemove = _coverageDistanceLabelSymbols.keys + .where((k) => !wanted.contains(k)) + .toList(); + for (final k in toRemove) { + final sym = _coverageDistanceLabelSymbols.remove(k); + if (sym != null) { + try { + await _mapController!.removeSymbol(sym); + } catch (_) {} + } + } + } + + /// Remove all coverage distance-label pills. + Future _clearCoverageDistanceLabels() async { + if (_mapController == null) return; + final toRemove = List.of(_coverageDistanceLabelSymbols.values); + _coverageDistanceLabelSymbols.clear(); + for (final sym in toRemove) { + try { + await _mapController!.removeSymbol(sym); + } catch (_) {} + } + } + + /// Restore all repeaters hidden by a Feature A fan-out (clears the heard set + /// and re-pushes the repeater source). No-op when no fade is active. No + /// setState — the source is pushed imperatively, like [_clearRepeaterIsolation]. + void _restoreFadedRepeaters() { + if (_coverageHeardRepeaterIds == null) return; + _coverageHeardRepeaterIds = null; + if (!mounted) return; + _syncRepeaterSymbols(context.read()); + } + + /// Tear down ALL community coverage visuals (lines, cells, labels, fade). + /// The single funnel used by every entry/exit path. + Future _clearCoverageConnections() async { + _restoreFadedRepeaters(); + _restoreCoverageBackdropForRepeater(); + await _clearCoverageLines(); + await _clearCoverageCells(); + await _clearCoverageDistanceLabels(); + } + + /// Feature B draw: render [matched] (the points that heard [repeater]) as + /// deduped status-coloured coverage cells plus a status-coloured dashed line + /// from the repeater to each cell centre. The lines layer is ensured BEFORE + /// the cells layer so the lines read on top. Returns the (capped) cells so the + /// caller can frame them with the repeater. + Future> _drawRepeaterCoverage(Repeater repeater, + List> matched, String cvd, int gridSize) async { + final steps = kCoverageGridSteps[gridSize] ?? const [0.0009, 0.00128]; + final blob = gridSize == 100 ? 1 : 0; + // High cap so a repeater's full footprint draws (web parity — it draws all). + // Still bounded (farthest-first) to protect against pathological volumes. + final cells = _capByFarthest( + repeaterCoverageCells(matched, repeater, + latStep: steps[0], lonStep: steps[1], blob: blob), + (c) => c.distanceMeters, + 5000, + '[COVERAGE] repeater coverage', + ); + if (cells.isEmpty) { + await _clearCoverageCells(); + await _clearCoverageLines(); + return cells; + } + await _ensureCoverageLinesLayer(); + await _updateCoverageCells(cells, cvd, steps[0], steps[1]); + final segments = [ + for (final c in cells) + ( + lat: c.centerLat, + lon: c.centerLon, + color: CoverageTilePalette.colorsForStatus(cvd, c.st)[0], + ), + ]; + // Skinnier than the tile fan-out — a repeater draws many lines at once. + await _updateCoverageLines(segments, repeater.lat, repeater.lon, + width: 1.5); + // Dim the base coverage tiles so the repeater's coloured cells + lines pop + // (web `drawRepeaterCoverageFromCache` tile-dim parity). Restored in + // _clearRepeaterIsolation. Skip while ping focus already hid the overlay. + if (_focusedPingLocation == null) { + _coverageDimmedForRepeater = true; + await _applyCoverageOverlayOpacity(_kCellHighlightFadeOpacity); + } + return cells; + } + + /// Rebuilds the regional boundary layer from `appState.regionBorders`. + /// Always-on: renders whenever polygons are present, independent of BLE + /// or auth state. Idempotent — safe to call repeatedly (removes existing + /// source/layers first). + Future _refreshRegionBorders(AppStateProvider appState) async { + if (_mapController == null || !_styleLoaded) return; + + // Remove existing layers (label, line) and source. Order matters: layers + // reference the source, so they must go first. Each try/catch tolerates + // a missing layer on the first call. + try { + await _mapController!.removeLayer(_regionBorderLabelLayerId); + } catch (_) {} + try { + await _mapController!.removeLayer(_regionBorderLineLayerId); + } catch (_) {} + try { + await _mapController!.removeSource(_regionBorderSourceId); + } catch (_) {} + + final borders = appState.regionBorders; + if (borders.isEmpty) return; + + // Build a FeatureCollection. API sends `[lat, lon]` pairs; GeoJSON wants + // `[lon, lat]` — flip during conversion. Polygon rings must be closed, + // so append the first point if the last doesn't already match. + final features = >[]; + for (final entry in borders) { + final code = entry['code']?.toString() ?? ''; + final raw = entry['polygon']; + if (raw is! List || raw.length < 3) continue; + + final ring = >[]; + for (final pt in raw) { + if (pt is! List || pt.length < 2) continue; + final lat = (pt[0] as num).toDouble(); + final lon = (pt[1] as num).toDouble(); + ring.add([lon, lat]); + } + if (ring.length < 3) continue; + if (ring.first[0] != ring.last[0] || ring.first[1] != ring.last[1]) { + ring.add([ring.first[0], ring.first[1]]); + } + + features.add({ + 'type': 'Feature', + 'properties': { + 'iata': code, + 'label': '$code BOUNDARY', + }, + 'geometry': { + 'type': 'Polygon', + 'coordinates': [ring], + }, + }); + } + + if (features.isEmpty) return; + + final geojson = { + 'type': 'FeatureCollection', + 'features': features, + }; + + try { + await _mapController!.addSource( + _regionBorderSourceId, + GeojsonSourceProperties(data: geojson), + ); + + // Render beneath the repeater cluster so repeaters stay tappable on top. + // Fallback gracefully if cluster layer isn't ready yet. + final belowLayer = + _clusterLayersReady ? _repeaterClusterBubbleLayerId : null; + + await _mapController!.addLineLayer( + _regionBorderSourceId, + _regionBorderLineLayerId, + const LineLayerProperties( + lineColor: '#FF6A00', + lineOpacity: 0.9, + lineWidth: 3.0, + lineCap: 'round', + lineJoin: 'round', + ), + belowLayerId: belowLayer, + ); + + await _mapController!.addSymbolLayer( + _regionBorderSourceId, + _regionBorderLabelLayerId, + const SymbolLayerProperties( + symbolPlacement: 'line', + textField: ['get', 'label'], + textSize: 12, + textColor: '#FF6A00', + textHaloColor: '#FFFFFF', + textHaloWidth: 1.5, + textKeepUpright: true, + textFont: _defaultFontStack, + ), + minzoom: 13, + belowLayerId: belowLayer, + ); + } catch (e) { + debugError('[MAP] Failed to add region border layer: $e'); + } + } + + /// Shows the dialog explaining how to expand the regional boundary. + void _showBorderInfoDialog() { + if (!mounted) return; + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Region Boundary'), + content: const Text( + 'To expand the boundary, talk to your MeshMapper regional admin.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } + + /// Diff-syncs the distance label symbols shown in focus mode. Each label is + /// a bitmap pill (white text on a dark rounded rectangle background, baked + /// into an addImage icon) placed at the midpoint of the ping→repeater line. + /// + /// A later pass ([_reflowDistanceLabelsForCollisions]) may slide individual + /// labels along their lines after the zoom-to-fit animation settles, to + /// prevent them from overlapping on screen. + Future _syncDistanceLabels(AppStateProvider appState) async { + if (_mapController == null || !_styleLoaded) return; + + // No focus → remove all existing labels and wipe the tracking maps. + // + // Order matters here: snapshot the symbols to remove and clear the + // tracking maps SYNCHRONOUSLY before awaiting any removeSymbol call. + // + // Why: removeSymbol is async. If we cleared after the await loop, a + // concurrent _syncDistanceLabels call (triggered by e.g. the user + // tapping a new ping and its focus activating during the yield) would + // see the old tracking data — populate new symbols for the new focus + // into the still-populated map — and then our late `.clear()` would + // wipe the new-focus entries from tracking, leaving orphaned native + // symbols on the map and causing the NEXT sync to double-add them. + // By clearing first, any concurrent sync starts from a clean slate. + if (_focusedPingLocation == null || _focusedRepeaters.isEmpty) { + final toRemove = List.of(_distanceLabelSymbols.values); + _distanceLabelSymbols.clear(); + _distanceLabelImageSize.clear(); + _distanceLabelRepeaterPos.clear(); + for (final sym in toRemove) { + try { + await _mapController!.removeSymbol(sym); + } catch (_) {} + } + return; + } + + final isImperial = appState.preferences.isImperial; + final ping = _focusedPingLocation!; + final wantedKeys = {}; + + for (final r in _focusedRepeaters) { + final key = r.repeater.id; + wantedKeys.add(key); + final midLat = (ping.latitude + r.repeater.lat) / 2; + final midLon = (ping.longitude + r.repeater.lon) / 2; + final meters = GpsService.distanceBetween( + ping.latitude, + ping.longitude, + r.repeater.lat, + r.repeater.lon, + ); + final labelText = meters < 1000 + ? formatMeters(meters, isImperial: isImperial) + : formatKilometers(meters / 1000, isImperial: isImperial); + + // Dedup the bitmap image by label text — identical distances reuse one + // registered image. addImage is idempotent by name, so re-registering + // the same name is a no-op on subsequent calls. + final imageName = 'distance-label-${labelText.hashCode}'; + Size? imageSize; + if (!_registeredDistanceLabelImages.contains(imageName)) { + try { + final rendered = await _renderDistanceLabelPng(labelText); + await _mapController!.addImage(imageName, rendered.bytes); + _registeredDistanceLabelImages.add(imageName); + _registeredDistanceLabelImageSizes[imageName] = rendered.size; + imageSize = rendered.size; + } catch (e) { + debugError('[MAP] render/addImage(distance label) failed: $e'); + } + } + imageSize ??= + _registeredDistanceLabelImageSizes[imageName] ?? const Size(60, 18); + _distanceLabelImageSize[key] = imageSize; + _distanceLabelRepeaterPos[key] = LatLng(r.repeater.lat, r.repeater.lon); + + final options = SymbolOptions( + geometry: LatLng(midLat, midLon), + iconImage: imageName, + iconSize: 1.0, + iconAnchor: 'center', + // Rotate the pill to follow its ping→repeater line (kept upright), so + // ping focus matches the tile/repeater coverage labels. + iconRotate: _lineLabelRotation( + ping.latitude, ping.longitude, r.repeater.lat, r.repeater.lon), + ); + + final existing = _distanceLabelSymbols[key]; + if (existing == null) { + try { + _distanceLabelSymbols[key] = await _mapController!.addSymbol( + options, + {'kind': 'distance'}, + ); + } catch (e) { + debugError('[MAP] addSymbol(distance) failed: $e'); + } + } else { + try { + await _mapController!.updateSymbol(existing, options); + } catch (e) { + debugError('[MAP] updateSymbol(distance) failed: $e'); + } + } + } + + // Remove labels for repeaters no longer in focus + final toRemove = _distanceLabelSymbols.keys + .where((k) => !wantedKeys.contains(k)) + .toList(); + for (final key in toRemove) { + final sym = _distanceLabelSymbols.remove(key); + _distanceLabelImageSize.remove(key); + _distanceLabelRepeaterPos.remove(key); + if (sym != null) { + try { + await _mapController!.removeSymbol(sym); + } catch (_) {} + } + } + } + + /// After the focus zoom-to-fit animation settles, walks the placed distance + /// labels and slides any that overlap on screen to a different position + /// along their ping→repeater line. Uses toScreenLocationBatch to sample + /// candidate t values (0.5, 0.4, 0.6, 0.3, 0.7, 0.25, 0.75) for each label + /// and greedily picks the first non-colliding slot. + Future _reflowDistanceLabelsForCollisions() async { + if (_mapController == null || !mounted) return; + if (_focusedPingLocation == null) return; + if (_distanceLabelSymbols.isEmpty) return; + + final ping = _focusedPingLocation!; + // Deterministic order: iterate focused repeaters in the list order we got + // them in (SNR-ranked upstream), so the "primary" label wins t=0.5. + final orderedIds = _focusedRepeaters + .map((r) => r.repeater.id) + .where(_distanceLabelSymbols.containsKey) + .toList(); + if (orderedIds.isEmpty) return; + + // Candidate t values to try, in preference order. + const candidateTs = [0.5, 0.4, 0.6, 0.3, 0.7, 0.25, 0.75]; + + // Step 1: compute all candidate LatLngs for every label so we can batch + // the toScreenLocation calls (one round-trip instead of N×T). + final candidateLatLngs = []; + for (final id in orderedIds) { + final repeaterPos = _distanceLabelRepeaterPos[id]; + if (repeaterPos == null) continue; + for (final t in candidateTs) { + candidateLatLngs.add(LatLng( + ping.latitude + (repeaterPos.latitude - ping.latitude) * t, + ping.longitude + (repeaterPos.longitude - ping.longitude) * t, + )); + } + } + + List> screenPoints; + try { + screenPoints = + await _mapController!.toScreenLocationBatch(candidateLatLngs); + } catch (e) { + debugError('[MAP] toScreenLocationBatch(distance labels) failed: $e'); + return; + } + if (!mounted || _focusedPingLocation == null) return; + + // Step 2: greedily place each label at the first candidate t whose + // screen rect doesn't overlap any already-placed label rect. + const gap = 4.0; // extra spacing between pills in logical pixels + final placedRects = []; + var cursor = 0; + for (final id in orderedIds) { + final repeaterPos = _distanceLabelRepeaterPos[id]; + final labelSize = _distanceLabelImageSize[id] ?? const Size(60, 18); + if (repeaterPos == null) { + cursor += candidateTs.length; + continue; + } + + int bestIdx = 0; + Rect? bestRect; + for (var i = 0; i < candidateTs.length; i++) { + final sp = screenPoints[cursor + i]; + final rect = Rect.fromCenter( + center: Offset(sp.x.toDouble(), sp.y.toDouble()), + width: labelSize.width + gap, + height: labelSize.height + gap, + ); + final collides = placedRects.any((r) => r.overlaps(rect)); + if (!collides) { + bestIdx = i; + bestRect = rect; + break; + } + // Fallback: keep the first candidate rect so we still place somewhere + // if every slot collides. + bestRect ??= rect; + } + + final tChosen = candidateTs[bestIdx]; + final targetLatLng = LatLng( + ping.latitude + (repeaterPos.latitude - ping.latitude) * tChosen, + ping.longitude + (repeaterPos.longitude - ping.longitude) * tChosen, + ); + placedRects.add(bestRect!); + + final symbol = _distanceLabelSymbols[id]; + if (symbol != null) { + try { + await _mapController!.updateSymbol( + symbol, + // Re-assert the line rotation (unchanged — sliding along the same + // line) alongside the new position. + SymbolOptions( + geometry: targetLatLng, + iconRotate: _lineLabelRotation(ping.latitude, ping.longitude, + repeaterPos.latitude, repeaterPos.longitude), + ), + ); + } catch (e) { + debugError('[MAP] updateSymbol(distance reflow) failed: $e'); + } + } + + cursor += candidateTs.length; + } + } + + /// Single entry point that syncs all native annotations against current + /// app state. Called from the post-frame callback in [build] when the + /// marker data version changes (so we don't sync on every camera tick). + Future _syncAllAnnotations(AppStateProvider appState) async { + await _syncRepeaterSymbols(appState); + await _syncCoverageSymbols(appState); + await _syncGpsSymbol(appState); + if (!_focusSyncDeferred) { + await _updateFocusLines(); + await _syncDistanceLabels(appState); + } + } + + /// Fit camera to show all history session markers + void _fitCameraToHistoryMarkers(List markers) { + final pts = markers + .where((m) => + m.latitude != null && + m.longitude != null && + isValidLatLng(m.latitude!, m.longitude!)) + .toList(); + if (pts.isEmpty) return; + + if (pts.length == 1) { + _animateToPositionWithZoom( + LatLng(pts.first.latitude!, pts.first.longitude!), + 16.0 - _zoomEpsilon, + ); + return; + } + + double minLat = pts.first.latitude!; + double maxLat = pts.first.latitude!; + double minLon = pts.first.longitude!; + double maxLon = pts.first.longitude!; + for (final p in pts) { + 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!; + } + + _animateFitBounds( + minLat: minLat, + maxLat: maxLat, + minLon: minLon, + maxLon: maxLon, + ); + } + + /// Map PingEventType to coverage marker type and success state + static ({String type, bool success}) _historyMarkerType(PingEventType t) => + switch (t) { + PingEventType.txSuccess => (type: 'tx', success: true), + PingEventType.txFail => (type: 'tx', success: false), + PingEventType.rx => (type: 'rx', success: true), + PingEventType.discSuccess => (type: 'disc', success: true), + PingEventType.discFail => (type: 'disc', success: false), + PingEventType.traceSuccess => (type: 'trace', success: true), + PingEventType.traceFail => (type: 'trace', success: false), + PingEventType.txMultiHopOnly => (type: 'rx', success: true), + }; + + /// Compute a version hash of all data that affects the marker list. + /// When this changes, the cached marker list is rebuilt; otherwise it's reused + /// across camera-change rebuilds (which happen at ~60Hz during pan/zoom). + /// + /// Captures **in-place** mutations too: TX pings grow `heardRepeaters` during + /// the 7s echo window, and DISC entries grow `discoveredNodes` as late + /// responses land. Summing counts makes the hash sensitive to these additions + /// even though the parent list length doesn't change. + int _computeMarkerDataVersion(AppStateProvider appState) { + int txEchoTotal = 0; + for (final p in appState.txPings) { + txEchoTotal += p.heardRepeaters.length; + } + int discNodeTotal = 0; + for (final e in appState.discLogEntries) { + discNodeTotal += e.discoveredNodes.length; + } + int traceSuccessTotal = 0; + for (final t in appState.traceLogEntries) { + if (t.success) traceSuccessTotal++; + } + + return Object.hash( + appState.txPings.length, + appState.rxPings.length, + appState.discLogEntries.length, + appState.traceLogEntries.length, + appState.repeaters.length, + appState.discDropEnabled, + appState.enforceHopBytes, + appState.effectiveHopBytes, + _focusedPingLocation, + _focusedPingTimestamp, + _focusedRepeaters.length, + appState.preferences.gpsMarkerStyle, + appState.preferences.markerStyle, + txEchoTotal, + discNodeTotal, + traceSuccessTotal, + appState.viewingHistorySession, + appState.historySessionMarkers?.length ?? 0, + ); + } - return Column( - mainAxisSize: MainAxisSize.min, + /// Color for the overlay ping-type dot + static Color _overlayTypeColor(OverlayPingType type) { + return switch (type) { + OverlayPingType.tx => PingColors.txSuccess, + OverlayPingType.disc => PingColors.discSuccess, + OverlayPingType.trace => PingColors.traceSuccess, + OverlayPingType.rx => PingColors.rx, + }; + } + + /// Build a single overlay table row with colored dot, repeater ID, and SNR + TableRow _overlayRow(String repeaterId, double snr, Color dotColor) { + return TableRow( children: [ - // Toggle button (always visible) - at top - GestureDetector( - onTap: onToggle, - child: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.7), - borderRadius: isExpanded - ? const BorderRadius.vertical(top: Radius.circular(8)) - : BorderRadius.circular(8), + TableCell( + verticalAlignment: TableCellVerticalAlignment.middle, + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: Container( + width: 6, + height: 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: dotColor, + ), ), - child: Icon( - isExpanded ? Icons.expand_less : Icons.expand_more, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Text( + repeaterId, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', color: Colors.white, - size: 22, ), ), ), - // Map controls (only when expanded) - below the toggle button - if (isExpanded) - _buildMapControls(appState), + const SizedBox(), + Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Text( + snr.toStringAsFixed(1), + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: _snrColor(snr), + ), + ), + ), ], ); } - Widget _buildMap(AppStateProvider appState, LatLng center) { - return Builder( - builder: (context) => FlutterMap( - mapController: _mapController, - options: MapOptions( - initialCenter: center, - initialZoom: _defaultZoom, - minZoom: 3, - maxZoom: 17, - interactionOptions: InteractionOptions( - flags: _rotationLocked - ? InteractiveFlag.all & ~InteractiveFlag.rotate - : InteractiveFlag.all, - ), - onMapReady: () { - _isMapReady = true; - // Initial center on GPS if available - if (appState.currentPosition != null) { - _mapController.move(center, _defaultZoom); - } - }, - ), - children: [ - // Tile layer (dynamic based on selected style from preferences) - Builder( - builder: (context) { - final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); - return TileLayer( - urlTemplate: mapStyle.urlTemplate, - subdomains: mapStyle.subdomains ?? const [], - userAgentPackageName: 'com.meshmapper.app', - maxZoom: 17, - retinaMode: mapStyle.supportsRetina && RetinaMode.isHighDensity(context), - tileProvider: SilentCancellableNetworkTileProvider(), - ); - }, - ), + /// GPS info overlay (top-left corner) + /// Top heard repeaters overlay (bottom-right of map) + Widget _buildTopRepeatersOverlay(AppStateProvider appState) { + final topRepeaters = appState.topRepeatersBySnr; + final rxSlot = appState.rxOverlaySlot; + final isEmpty = topRepeaters.isEmpty && rxSlot == null; - // MeshMapper coverage overlay (only when zone code available and overlay enabled) - if (appState.zoneCode != null && _showMeshMapperOverlay) - TileLayer( - urlTemplate: 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}', - userAgentPackageName: 'com.meshmapper.app', - minZoom: 3, - maxZoom: 17, - tileDisplay: const TileDisplay.fadeIn( - reloadStartOpacity: 1.0, // Keep old tile visible until new one loads - ), - tileProvider: SilentCancellableNetworkTileProvider(), + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Top Heard', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w500, + color: Colors.white54, + letterSpacing: 0.5, ), - - // TX markers (green) - MarkerLayer( - markers: _buildTxMarkers(appState.txPings), - ), - - // RX markers (colored by repeater) - MarkerLayer( - markers: _buildRxMarkers(appState.rxPings), - ), - - // DISC markers (purple circles for discovery observations) - MarkerLayer( - markers: _buildDiscMarkers(appState.discLogEntries, appState.discDropEnabled), - ), - - // Repeater markers (magenta with ID, rotate with map) - MarkerLayer( - rotate: true, - markers: _buildRepeaterMarkers( - appState.repeaters, - appState.enforceHopBytes ? appState.effectiveHopBytes : null, ), - ), - - // Current position marker (car icon) - if (appState.currentPosition != null) - MarkerLayer( - markers: [ - Marker( - point: LatLng( - appState.currentPosition!.latitude, - appState.currentPosition!.longitude, - ), - width: 48, - height: 48, - child: _buildCurrentPositionMarker(appState.currentPosition!.heading), + const SizedBox(height: 2), + if (isEmpty) + const Text( + '---', + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: Colors.white38, ), - ], - ), + ), + if (!isEmpty) + Table( + defaultColumnWidth: const IntrinsicColumnWidth(), + columnWidths: const { + 0: IntrinsicColumnWidth(), // dot + 1: IntrinsicColumnWidth(), // ID + 2: FixedColumnWidth(8), // spacer + 3: IntrinsicColumnWidth(), // SNR + }, + children: [ + for (final r in topRepeaters) + _overlayRow(r.repeaterId, r.snr, _overlayTypeColor(r.type)), + if (rxSlot != null) + _overlayRow(rxSlot.repeaterId, rxSlot.snr, + _overlayTypeColor(OverlayPingType.rx)), + ], + ), ], ), ); } - /// GPS info overlay (top-left corner) + /// SNR color (delegates to active palette) + static Color _snrColor(double snr) => PingColors.snrColor(snr); + Widget _buildGpsInfoOverlay(AppStateProvider appState) { final position = appState.currentPosition; final hasGps = position != null; @@ -738,14 +6084,18 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), const SizedBox(width: 6), Text( - hasGps ? formatMeters(position.accuracy, isImperial: appState.preferences.isImperial) : 'No GPS', + hasGps + ? formatMeters(position.accuracy, + isImperial: appState.preferences.isImperial) + : 'No GPS', style: TextStyle( fontSize: 11, fontFamily: 'monospace', - color: hasGps ? _getAccuracyColor(position.accuracy) : Colors.grey, + color: + hasGps ? _getAccuracyColor(position.accuracy) : Colors.grey, ), ), - // Distance since last TX ping (like wardrive.js) + // Distance since the last ping of any type (TX, discovery, trace) if (hasGps && distanceFromLastPing != null) ...[ const SizedBox(width: 12), const Icon( @@ -755,7 +6105,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), const SizedBox(width: 4), Text( - formatMeters(distanceFromLastPing, isImperial: appState.preferences.isImperial), + formatMeters(distanceFromLastPing, + isImperial: appState.preferences.isImperial), style: const TextStyle( fontSize: 11, fontFamily: 'monospace', @@ -769,14 +6120,105 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } Color _getAccuracyColor(double accuracy) { - if (accuracy <= 10) return Colors.green; - if (accuracy <= 30) return Colors.orange; - return Colors.red; + if (accuracy <= 10) return PingColors.signalGood; + if (accuracy <= 30) return PingColors.signalMedium; + return PingColors.signalBad; } - /// Map controls (always vertical, used inside collapsible wrapper) + /// Map controls. Single vertical column in portrait; in landscape the taller + /// set is split into two columns so the lower icons don't run off the short + /// viewport (hidden under the bottom edge) — see issue #329 follow-up. Widget _buildMapControls(AppStateProvider appState) { - final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); + final mapStyle = + MapStyleExtension.fromString(appState.preferences.mapStyle); + + // Collect the active control buttons (conditionals preserved); dividers are + // interleaved later by _stackControlButtons so the two-column split is clean. + final buttons = [ + // Map style toggle + _buildControlButton( + icon: mapStyle.icon, + tooltip: 'Map Style: ${mapStyle.label}', + onPressed: () => _cycleMapStyle(appState), + ), + // MeshMapper overlay toggle (only show when zone code available) + if (appState.zoneCode != null) + _buildControlButton( + icon: Icons.layers, + tooltip: _showMeshMapperOverlay + ? 'Hide Coverage Overlay' + : 'Show Coverage Overlay', + onPressed: _toggleMeshMapperOverlay, + isActive: _showMeshMapperOverlay, + ), + // Repeater pins toggle (only show when repeaters are present) + if (appState.repeaters.isNotEmpty) + _buildControlButton( + icon: Icons.cell_tower, + tooltip: _showRepeaters ? 'Hide Repeaters' : 'Show Repeaters', + onPressed: () => _toggleRepeaters(appState), + isActive: _showRepeaters, + ), + // Region boundary toggle (only show when borders available) + if (appState.regionBorders.isNotEmpty) + _buildControlButton( + icon: Icons.fence, + tooltip: _showRegionBorders + ? 'Hide Region Boundary' + : 'Show Region Boundary', + onPressed: () => _toggleRegionBorders(appState), + isActive: _showRegionBorders, + ), + // Center on position / toggle auto-follow + _buildControlButton( + icon: _autoFollow ? Icons.my_location : Icons.location_searching, + tooltip: _autoFollow ? 'Following GPS' : 'Center on Position', + onPressed: appState.currentPosition != null ? _centerOnPosition : null, + isActive: _autoFollow, + ), + // Always North toggle + _buildControlButton( + icon: _alwaysNorth ? Icons.navigation : Icons.explore, + tooltip: _alwaysNorth + ? 'Always North (Click to Rotate with Heading)' + : 'Rotating with Heading (Click for Always North)', + onPressed: _toggleNorthMode, + isActive: !_alwaysNorth, + ), + // Rotation lock toggle + _buildControlButton( + icon: _rotationLocked ? Icons.sync_disabled : Icons.rotate_right, + tooltip: _rotationLocked ? 'Unlock Rotation' : 'Lock Rotation', + onPressed: _toggleRotationLock, + isActive: _rotationLocked, + ), + // Legend button + _buildControlButton( + icon: Icons.info_outline, + tooltip: 'Legend & Info', + onPressed: _showLegendPopup, + ), + ]; + + final isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; + + final Widget body; + if (isLandscape && buttons.length > 4) { + // Split into two side-by-side columns so the set fits vertically. + final half = (buttons.length / 2).ceil(); + body = Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _stackControlButtons(buttons.sublist(0, half)), + Container(width: 1, color: Colors.white24), + _stackControlButtons(buttons.sublist(half)), + ], + ); + } else { + body = _stackControlButtons(buttons); + } return Container( decoration: BoxDecoration( @@ -784,58 +6226,21 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Controls are below the toggle button, so rounded bottom only borderRadius: const BorderRadius.vertical(bottom: Radius.circular(8)), ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Map style toggle - _buildControlButton( - icon: mapStyle.icon, - tooltip: 'Map Style: ${mapStyle.label}', - onPressed: () => _cycleMapStyle(appState), - ), - // MeshMapper overlay toggle (only show when zone code available) - if (appState.zoneCode != null) ...[ - _buildControlDivider(), - _buildControlButton( - icon: Icons.layers, - tooltip: _showMeshMapperOverlay ? 'Hide Coverage Overlay' : 'Show Coverage Overlay', - onPressed: _toggleMeshMapperOverlay, - isActive: _showMeshMapperOverlay, - ), - ], - _buildControlDivider(), - // Center on position / toggle auto-follow - _buildControlButton( - icon: _autoFollow ? Icons.my_location : Icons.location_searching, - tooltip: _autoFollow ? 'Following GPS' : 'Center on Position', - onPressed: appState.currentPosition != null ? _centerOnPosition : null, - isActive: _autoFollow, - ), - _buildControlDivider(), - // Always North toggle - _buildControlButton( - icon: _alwaysNorth ? Icons.navigation : Icons.explore, - tooltip: _alwaysNorth ? 'Always North (Click to Rotate with Heading)' : 'Rotating with Heading (Click for Always North)', - onPressed: _toggleNorthMode, - isActive: !_alwaysNorth, - ), - _buildControlDivider(), - // Rotation lock toggle - _buildControlButton( - icon: _rotationLocked ? Icons.sync_disabled : Icons.rotate_right, - tooltip: _rotationLocked ? 'Unlock Rotation' : 'Lock Rotation', - onPressed: _toggleRotationLock, - isActive: _rotationLocked, - ), - _buildControlDivider(), - // Legend button - _buildControlButton( - icon: Icons.info_outline, - tooltip: 'Legend & Info', - onPressed: _showLegendPopup, - ), - ], - ), + child: body, + ); + } + + /// Stack control buttons vertically with a divider between each (no leading or + /// trailing divider), matching the original single-column look. + Widget _stackControlButtons(List buttons) { + final children = []; + for (var i = 0; i < buttons.length; i++) { + if (i > 0) children.add(_buildControlDivider()); + children.add(buttons[i]); + } + return Column( + mainAxisSize: MainAxisSize.min, + children: children, ); } @@ -877,7 +6282,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { void _cycleMapStyle(AppStateProvider appState) { const styles = MapStyle.values; - final currentStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); + final currentStyle = + MapStyleExtension.fromString(appState.preferences.mapStyle); final currentIndex = styles.indexOf(currentStyle); final newStyle = styles[(currentIndex + 1) % styles.length]; appState.setMapStyle(newStyle.name); @@ -889,6 +6295,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { if (_autoFollow) { setState(() { _autoFollow = false; + _autoFollowDesiredZoom = null; }); appState.setMapAutoFollow(false); return; @@ -900,14 +6307,31 @@ class _MapWidgetState extends State with TickerProviderStateMixin { appState.currentPosition!.latitude, appState.currentPosition!.longitude, ); + const targetZoom = + _maxUserZoom; // Street-level zoom when enabling follow (already nudged off integer) setState(() { _autoFollow = true; _lastGpsPosition = targetPosition; + _autoFollowDesiredZoom = targetZoom; }); appState.setMapAutoFollow(true); - // Apply offset for bottom padding when control panel is open - final adjustedPosition = _offsetPositionForPadding(targetPosition, widget.bottomPaddingPixels, widget.rightPaddingPixels); - _animateToPositionWithZoom(adjustedPosition, 17.0); // Street level zoom when enabling follow + // Bundle target + zoom + bearing into one animation so the + // initial centering can't be half-cancelled by a racing GPS tick. + final double targetBearing = + (!_alwaysNorth && _computedHeading != null) ? _computedHeading! : 0.0; + final adjustedPosition = _offsetPositionForPadding( + targetPosition, + widget.bottomPaddingPixels, + widget.rightPaddingPixels, + targetZoom, + targetBearing, + ); + _animateAutoFollowCamera( + target: adjustedPosition, + zoom: targetZoom, + bearing: targetBearing, + durationMs: 500, + ); } } @@ -915,6 +6339,44 @@ class _MapWidgetState extends State with TickerProviderStateMixin { setState(() { _showMeshMapperOverlay = !_showMeshMapperOverlay; }); + if (_showMeshMapperOverlay) { + _addCoverageOverlay(context.read()); + } else { + _removeCoverageOverlay(); + context.read().reportVectorOverlayActive(false); + } + } + + void _toggleRegionBorders(AppStateProvider appState) { + setState(() { + _showRegionBorders = !_showRegionBorders; + }); + if (_showRegionBorders) { + _refreshRegionBorders(appState); + } else { + _removeRegionBorders(); + } + } + + void _toggleRepeaters(AppStateProvider appState) { + setState(() { + _showRepeaters = !_showRepeaters; + }); + // Re-push the repeater source: real features when shown, empty when hidden. + _syncRepeaterSymbols(appState); + } + + void _removeRegionBorders() { + if (_mapController == null) return; + try { + _mapController!.removeLayer(_regionBorderLabelLayerId); + } catch (_) {} + try { + _mapController!.removeLayer(_regionBorderLineLayerId); + } catch (_) {} + try { + _mapController!.removeSource(_regionBorderSourceId); + } catch (_) {} } void _toggleNorthMode() { @@ -923,49 +6385,25 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _alwaysNorth = !_alwaysNorth; // If switching to Always North mode, smoothly rotate map back to north - if (_alwaysNorth && _isMapReady) { - // Reset heading tracking + if (_alwaysNorth && _isMapReady && _mapController != null) { _lastHeading = null; - // Smoothly rotate back to north (0 degrees) - final currentRotation = _mapController.camera.rotation; - if (currentRotation.abs() > 2) { - // Cancel any running rotation animation - _rotationAnimationController?.stop(); - _rotationAnimationController?.dispose(); - - // Create animation to rotate back to north - const duration = Duration(milliseconds: 500); - _rotationAnimationController = AnimationController( - duration: duration, - vsync: this, - ); - - _rotationAnimation = CurvedAnimation( - parent: _rotationAnimationController!, - curve: Curves.easeInOutCubic, + final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; + if (currentBearing.abs() > 2 && _canAnimateCamera) { + _mapController!.animateCamera( + CameraUpdate.bearingTo(0), + duration: const Duration(milliseconds: 500), ); - - _rotationStartAngle = currentRotation; - _rotationEndAngle = 0.0; // North - - _rotationAnimation!.addListener(() { - if (!mounted || _rotationStartAngle == null || _rotationEndAngle == null) return; - - final t = _rotationAnimation!.value; - final rotation = _rotationStartAngle! + - ((_rotationEndAngle! - _rotationStartAngle!) * t); - - _mapController.rotate(rotation); - }); - - _rotationAnimationController!.forward(); } } else if (!_alwaysNorth && appState.currentPosition != null) { // If switching to heading mode, immediately start rotating to current heading _lastHeading = null; // Force initial rotation + // Prefer our derived heading; fall back to whatever GPS reports (may + // be 0 if we haven't moved yet — better than no rotation at all). + final initialHeading = + _computedHeading ?? appState.currentPosition!.heading; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && !_alwaysNorth && appState.currentPosition != null) { - _animateToRotation(appState.currentPosition!.heading); + _animateToRotation(initialHeading); } }); } @@ -979,40 +6417,16 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _rotationLocked = !_rotationLocked; // When enabling lock in "Always North" mode, rotate back to north - // When in "Rotate with Heading" mode, keep current rotation - if (_rotationLocked && _isMapReady && _alwaysNorth) { - final currentRotation = _mapController.camera.rotation; - if (currentRotation.abs() > 2) { - // Cancel any running rotation animation - _rotationAnimationController?.stop(); - _rotationAnimationController?.dispose(); - - // Create animation to rotate back to north - const duration = Duration(milliseconds: 500); - _rotationAnimationController = AnimationController( - duration: duration, - vsync: this, - ); - - _rotationAnimation = CurvedAnimation( - parent: _rotationAnimationController!, - curve: Curves.easeInOutCubic, + if (_rotationLocked && + _isMapReady && + _alwaysNorth && + _mapController != null) { + final currentBearing = _mapController!.cameraPosition?.bearing ?? 0; + if (currentBearing.abs() > 2 && _canAnimateCamera) { + _mapController!.animateCamera( + CameraUpdate.bearingTo(0), + duration: const Duration(milliseconds: 500), ); - - _rotationStartAngle = currentRotation; - _rotationEndAngle = 0.0; // North - - _rotationAnimation!.addListener(() { - if (!mounted || _rotationStartAngle == null || _rotationEndAngle == null) return; - - final t = _rotationAnimation!.value; - final rotation = _rotationStartAngle! + - ((_rotationEndAngle! - _rotationStartAngle!) * t); - - _mapController.rotate(rotation); - }); - - _rotationAnimationController!.forward(); } } }); @@ -1043,7 +6457,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { decoration: BoxDecoration( color: Colors.blue.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.blue.withValues(alpha: 0.4)), + border: + Border.all(color: Colors.blue.withValues(alpha: 0.4)), ), child: const Icon(Icons.map, color: Colors.blue, size: 24), ), @@ -1052,8 +6467,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { child: Text( 'Legend & Info', style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), ), IconButton( @@ -1075,232 +6490,387 @@ class _MapWidgetState extends State with TickerProviderStateMixin { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Map Markers section - Text( - 'Map Markers', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Column( - children: [ - _buildLegendItem( - context: context, - color: const Color(0xFF22C55E), - label: 'TX', - description: 'Location where you sent a ping and heard a repeater', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLegendItem( - context: context, - color: Colors.red, - label: 'TX', - description: 'Location where you sent a ping but no repeater was heard', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLegendItem( - context: context, - color: const Color(0xFF0EA5E9), - label: 'RX', - description: 'Location where you received a message from the mesh', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLegendItem( - context: context, - color: const Color(0xFF7D54C7), - label: 'DISC', - description: 'Location where you sent a discovery request and a repeater responded', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLegendItem( - context: context, - color: Colors.grey, - label: 'DISC', - description: 'Location where you sent a discovery request but no repeater responded', - ), - ], - ), - ), - const SizedBox(height: 20), - - // Coverage Layer section - Text( - 'Coverage Layer', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Column( - children: [ - _buildLayerItem( - context: context, - color: const Color(0xFF7EE094), - label: 'BIDIR', - description: 'Heard repeats from the mesh AND successfully routed through it', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: const Color(0xFF51D4E9), - label: 'DISC', - description: 'Wardriving app sent a discovery packet and heard a reply', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: const Color(0xFFFD8928), - label: 'TX', - description: 'Successfully routed through, but no repeats heard back', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: const Color(0xFF7D54C7), - label: 'RX', - description: 'Heard mesh traffic but did not transmit', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: const Color(0xFF9E9689), - label: 'DEAD', - description: 'Repeater heard it, but no other radio received the repeat', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildLayerItem( - context: context, - color: const Color(0xFFE04F5D), - label: 'DROP', - description: 'No repeats heard AND no successful route', - ), - ], - ), - ), - const SizedBox(height: 20), + Text( + 'Map Markers', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + ), + child: Column( + children: [ + _buildLegendItem( + context: context, + color: PingColors.txSuccessLegend, + label: 'TX', + description: + 'Location where you sent a ping and heard a repeater', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: PingColors.txFail, + label: 'TX', + description: + 'Location where you sent a ping but no repeater was heard', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: PingColors.rx, + label: 'RX', + description: + 'Location where you received a message from the mesh', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: PingColors.discSuccess, + label: 'DISC', + description: + 'Location where you sent a discovery request and a repeater responded', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLegendItem( + context: context, + color: PingColors.traceSuccess, + label: 'TRC', + description: + 'Location where a trace reached the repeater', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.2)), + _buildLegendItem( + context: context, + color: PingColors.discFail, + label: 'DISC', + description: + 'Location where you sent a discovery request but no repeater responded', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.2)), + _buildLegendItem( + context: context, + color: PingColors.noResponse, + label: 'TRC', + description: + 'Location where a trace got no response', + ), + ], + ), + ), + const SizedBox(height: 20), - // Sound Notifications section - Text( - 'Sound Notifications', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Column( - children: [ - _buildSoundItem( - context: context, - icon: Icons.cell_tower, - label: 'TX Sound', - description: 'Plays when sending a ping or discovery request', - onPlay: () { - final appState = context.read(); - appState.audioService.playTransmitSound(); - }, - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildSoundItem( - context: context, - icon: Icons.hearing, - label: 'RX Sound', - description: 'Plays when a repeater echo or mesh message is received', - onPlay: () { - final appState = context.read(); - appState.audioService.playReceiveSound(); - }, - ), - ], - ), - ), - const SizedBox(height: 20), + // Coverage Layer section + Text( + 'Coverage Layer', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + ), + child: Column( + children: [ + _buildLayerItem( + context: context, + color: PingColors.coverageBidir, + label: 'BIDIR', + description: + 'Heard repeats from the mesh AND successfully routed through it', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageDisc, + label: 'DISC', + description: + 'Wardriving app sent a discovery packet and heard a reply', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageTx, + label: 'TX', + description: + 'Successfully routed through, but no repeats heard back', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageRx, + label: 'RX', + description: + 'Heard mesh traffic but did not transmit', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageDead, + label: 'DEAD', + description: + 'Repeater heard it, but no other radio received the repeat', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildLayerItem( + context: context, + color: PingColors.coverageDrop, + label: 'DROP', + description: + 'No repeats heard AND no successful route', + ), + ], + ), + ), + const SizedBox(height: 20), - // Map Controls section - Text( - 'Map Controls', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - ), - child: Column( - children: [ - _buildHelpItem( - context: context, - icon: Icons.dark_mode, - label: 'Map Style', - description: 'Cycle between Dark, Light, and Satellite map styles', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.layers, - label: 'Coverage Overlay', - description: 'Toggle MeshMapper coverage overlay showing community-reported mesh coverage', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.my_location, - label: 'Center/Follow', - description: 'Center map on GPS position. Tap again to toggle auto-follow mode', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.navigation, - label: 'Always North', - description: 'Toggle between always-north orientation or rotate with heading', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.sync_disabled, - label: 'Lock Rotation', - description: 'Prevent accidental rotation of the map', - ), - Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), - _buildHelpItem( - context: context, - icon: Icons.info_outline, - label: 'Legend & Info', - description: 'Show this help popup with legend and control explanations', - ), - ], - ), - ), + // Sound Notifications section + Text( + 'Sound Notifications', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + ), + child: Column( + children: [ + _buildSoundItem( + context: context, + icon: Icons.cell_tower, + label: 'TX Sound', + description: + 'Plays when sending a ping or discovery request', + onPlay: () { + final appState = + context.read(); + appState.audioService.playTransmitSound(); + }, + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildSoundItem( + context: context, + icon: Icons.hearing, + label: 'RX Sound', + description: + 'Plays when a repeater echo or mesh message is received', + onPlay: () { + final appState = + context.read(); + appState.audioService.playReceiveSound(); + }, + ), + ], + ), + ), + const SizedBox(height: 20), + + // Map Controls section + Text( + 'Map Controls', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + ), + child: Column( + children: [ + _buildHelpItem( + context: context, + icon: Icons.dark_mode, + label: 'Map Style', + description: + 'Cycle between Dark, Light, and Satellite map styles', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.layers, + label: 'Coverage Overlay', + description: + 'Toggle MeshMapper coverage overlay showing community-reported mesh coverage', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.my_location, + label: 'Center/Follow', + description: + 'Center map on GPS position. Tap again to toggle auto-follow mode', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.navigation, + label: 'Always North', + description: + 'Toggle between always-north orientation or rotate with heading', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.sync_disabled, + label: 'Lock Rotation', + description: + 'Prevent accidental rotation of the map', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.fence, + label: 'Region Boundary', + description: + 'Toggle the regional boundary outline and labels on the map', + ), + Divider( + height: 1, + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3)), + _buildHelpItem( + context: context, + icon: Icons.info_outline, + label: 'Legend & Info', + description: + 'Show this help popup with legend and control explanations', + ), + ], + ), + ), ], ), ), @@ -1317,8 +6887,13 @@ class _MapWidgetState extends State with TickerProviderStateMixin { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0), - Theme.of(context).colorScheme.surfaceContainerHighest, + Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0), + Theme.of(context) + .colorScheme + .surfaceContainerHighest, ], ), ), @@ -1511,121 +7086,1094 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } - List _buildTxMarkers(List pings) { - return pings.map((ping) { - return Marker( - point: LatLng(ping.latitude, ping.longitude), - width: 20, - height: 20, - child: GestureDetector( - onTap: () => _showTxPingDetails(ping), - child: Container( - decoration: BoxDecoration( - color: ping.heardRepeaters.isEmpty ? Colors.red : Colors.green, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), - // Simple dot - no arrow (looks good at any map rotation) + /// Build a coverage marker child widget based on the user's marker style preference. + /// Check if a ping at given lat/lon/timestamp is the currently focused ping. + /// Used by the native annotation sync to apply focus-mode styling (size, + /// opacity) to the focused ping vs other pings. + bool _isFocusedPing(double lat, double lon, DateTime timestamp) { + return _focusedPingLocation != null && + _focusedPingTimestamp == timestamp && + _focusedPingLocation!.latitude == lat && + _focusedPingLocation!.longitude == lon; + } + + void _showTraceDetails(TraceLogEntry entry, {bool fromMinimized = false}) { + // Default a real tap to the minimized 2-row pill — the full sheet opens only + // on expand (fromMinimized: true). _activatePingFocus engages focus for a + // successful trace's target or clears any prior focus / community view (empty + // list for a failed trace); set _focusedPingSource AFTER it (its missed branch + // dismisses a prior ping, which would otherwise clear the source). + if (!fromMinimized) { + final resolved = entry.success + ? _resolveRepeatersByHexIds( + [entry.targetRepeaterId], + snrValues: [entry.localSnr], + ) + : const <_ResolvedRepeater>[]; + _activatePingFocus( + LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); + _focusedPingSource = entry; + _minimizePingFocus(entry.timestamp); + return; + } + + showModalBottomSheet( + context: context, + useSafeArea: true, + isScrollControlled: true, + // Transparent barrier so the map stays fully bright during focus mode — + // the default Colors.black54 scrim would defeat the purpose of focus. + barrierColor: Colors.transparent, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.75, + ), + child: SingleChildScrollView( + child: Container( + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header with icon badge + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.cyan.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.cyan.withValues(alpha: 0.4)), + ), + child: const Icon(Icons.gps_fixed, + color: Colors.cyan, size: 24), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Trace', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + _formatTime(entry.timestamp), + style: TextStyle( + fontSize: 13, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: () => Navigator.pop(context, 'minimized'), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Minimize', + ), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + const SizedBox(height: 20), + + // Location chip + Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), + ), + child: Row( + children: [ + Icon(Icons.location_on, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + '${entry.latitude.toStringAsFixed(5)}, ${entry.longitude.toStringAsFixed(5)}', + style: TextStyle( + fontSize: 13, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Target repeater section header + Text( + entry.success + ? 'Target Repeater' + : 'No response from target repeater', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.5, + ), + ), + + if (entry.success) ...[ + const SizedBox(height: 12), + // Table with headers — reserve a sliver of node-column + // width for the inline `location_off` indicator when the + // target repeater has no GPS on file. + Builder(builder: (context) { + final chipWidth = _nodeColumnWidth(); + final lacksLocation = + _hexIdLacksLocation(entry.targetRepeaterId); + final iconReserve = lacksLocation ? 18.0 : 0.0; + final nodeColWidth = chipWidth + iconReserve; + return Container( + decoration: BoxDecoration( + color: + Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), + ), + child: Column( + children: [ + // Header row + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + child: Row( + children: [ + SizedBox( + width: nodeColWidth, + child: Text( + 'Node', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'RX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'RX RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + 'TX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ), + ], + ), + ), + Divider( + height: 1, color: Theme.of(context).dividerColor), + // Data row + Builder(builder: (context) { + final localSnr = entry.localSnr ?? 0; + final localRssi = entry.localRssi ?? 0; + final remoteSnr = entry.remoteSnr ?? 0; + + final rxSnrColor = PingColors.snrColor(localSnr); + final rssiColor = PingColors.rssiColor(localRssi); + final txSnrColor = + PingColors.snrColor(remoteSnr.toDouble()); + + return InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, entry.targetRepeaterId, fromLatLng: ( + lat: entry.latitude, + lon: entry.longitude + )), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + child: Row( + children: [ + // Repeater ID + optional no-location icon, + // pinned to the node column width so the + // SNR/RSSI/TX columns stay aligned. + SizedBox( + width: nodeColWidth, + child: Row( + children: [ + RepeaterIdChip( + repeaterId: + entry.targetRepeaterId, + fontSize: 13, + width: chipWidth), + if (lacksLocation) + Padding( + padding: const EdgeInsets.only( + left: 4), + child: _noLocationIndicator(), + ), + ], + ), + ), + // RX SNR + Expanded( + child: Center( + child: _buildStatChip( + value: localSnr.toStringAsFixed(1), + color: rxSnrColor, + ), + ), + ), + // RX RSSI + Expanded( + child: Center( + child: _buildStatChip( + value: '$localRssi', + color: rssiColor, + ), + ), + ), + // TX SNR + Expanded( + child: Center( + child: _buildStatChip( + value: remoteSnr.toStringAsFixed(1), + color: txSnrColor, + ), + ), + ), + ], + ), + ), + ); + }), + ], + ), + ); + }), + ], + ], + ), + ), + ), + ), + ).then((result) { + if (result == 'minimized') { + setState(() => _focusPanelMinimized = true); + } else { + _dismissPingFocus(); + } + }); + } + + /// DISC marker color (delegates to active palette) + static Color get _discMarkerColor => PingColors.discSuccess; + + /// Repeater marker color - Active (delegates to active palette) + static Color get _repeaterMarkerColor => PingColors.repeaterActive; + + /// Duplicate repeater marker color (delegates to active palette) + static Color get _repeaterDuplicateColor => PingColors.repeaterDuplicate; + + /// New repeater marker color (delegates to active palette) + static Color get _repeaterNewColor => PingColors.repeaterNew; + + /// Dead repeater marker color (delegates to active palette) + static Color get _repeaterDeadColor => PingColors.repeaterDead; + + /// Get set of duplicate repeater IDs + /// Resolve heard repeater hex IDs to Repeater objects with GPS coordinates. + /// Marks matches as ambiguous when a single hex ID matches multiple repeaters. + /// [snrValues] provides the SNR for each hex ID (same index) for line coloring. + List<_ResolvedRepeater> _resolveRepeatersByHexIds( + List hexIds, { + List fullHexIds = const [], + List snrValues = const [], + }) { + final allRepeaters = context.read().repeaters; + final resolved = <_ResolvedRepeater>[]; + for (int i = 0; i < hexIds.length; i++) { + final fullHex = i < fullHexIds.length ? fullHexIds[i] : null; + final snr = i < snrValues.length ? snrValues[i] : null; + final matchKey = (fullHex != null && fullHex.length >= 8) + ? fullHex.substring(0, 8) + : hexIds[i]; + final matches = allRepeaters + .where( + (r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) + .toList(); + final ambiguous = matches.length > 1; + resolved.addAll(matches.map((r) => _ResolvedRepeater(r, snr, ambiguous))); + } + return resolved; + } + + /// Look up the first matching [Repeater] by hex-ID prefix (case-insensitive). + /// Used by focus bottom-sheet rows to decide whether to surface the + /// `location_off` indicator. Returns null when no match is found — callers + /// treat that as "no location" too, since we have no coordinates. + Repeater? _lookupRepeaterByHexId(String hexId) { + if (hexId.isEmpty) return null; + final all = context.read().repeaters; + final key = hexId.toLowerCase(); + for (final r in all) { + if (r.hexId.toLowerCase().startsWith(key)) return r; + } + return null; + } + + /// True when we should show a "no location" hint for the given hex ID, + /// either because the matched repeater is at `(0, 0)` or because there is + /// no match at all. + bool _hexIdLacksLocation(String hexId) { + final r = _lookupRepeaterByHexId(hexId); + return r == null || !r.hasLocation; + } + + /// Small grey [Icons.location_off] used inline next to repeater chips in + /// the focus bottom sheets to signal "we heard this repeater but don't + /// know where it is". Tooltip explains on long-press. + Widget _noLocationIndicator() { + return Tooltip( + message: 'No location on file', + child: Icon( + Icons.location_off, + size: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + } + + /// Activate ping focus mode — draw lines, fade markers, zoom to fit. + void _activatePingFocus(LatLng pingLocation, DateTime timestamp, + List<_ResolvedRepeater> repeaters) { + // Repeaters lacking GPS would draw lines off to (0, 0); the bottom-sheet row + // builder still surfaces them with a no-location icon. + final located = + repeaters.where((r) => r.repeater.hasLocation).toList(growable: false); + + // "Dead" ping (nothing to focus): don't enter focus (north-up + hiding every + // marker over an empty map is jarring). Tear down any PRIOR ping focus first + // (its lines / hidden markers / overlay-dim — else they'd linger under this + // ping's pill); _dismissPingFocus no-ops when no ping is active. It also + // clears _focusedPingSource, so the caller re-sets it for THIS ping AFTER + // _activatePingFocus returns. Then close any open community view so the pill + // isn't shown over a stale fan-out (req: tapping a ping closes coverage). + if (located.isEmpty) { + _dismissPingFocus(); + _clearMinimizedInfoPopup(); + _clearCellHighlight(); + _clearRepeaterIsolation(); + _clearCoverageConnections(); + return; + } + + final alreadyInFocus = _focusedPingLocation != null; + // Claim ping focus as the active view FIRST, so the community teardowns below + // see a focus view active and don't restore the shared camera mid-switch + // (then ping re-fits) — switching from a coverage view to a ping animates once. + _focusedPingLocation = pingLocation; + + // Close any open community coverage view (pill + footprint + dim + isolation + + // lines/cells/fade). Each teardown's _exitFocusCameraIfDone no-ops because + // ping focus is now the active view. + _clearMinimizedInfoPopup(); + _clearCellHighlight(); + _clearRepeaterIsolation(); + _clearCoverageConnections(); + + // Engage the shared focus camera (saves the pre-focus snapshot once, + // north-up, stop follow). isFocusModeActive is a PING-only flag (it gates the + // home-screen map Selector); set it only for ping focus, only on first entry. + _enterFocusCamera(); + if (!alreadyInFocus) { + context.read().isFocusModeActive = true; + } + + _focusPanelMinimized = false; + + // Defer focus lines and distance labels so the 500ms zoom-to-fit + // animation runs without contention from heavy native platform calls. + _focusSyncDeferred = true; + + setState(() { + _focusedPingTimestamp = timestamp; + _focusedRepeaters = located; + }); + + // Hide the MeshMapper coverage raster overlay for a clean focus view. + // Uses opacity=0 rather than removing the layer to avoid a tile refetch + // on dismiss. No-ops gracefully if the layer isn't present. + _applyCoverageOverlayOpacity(0.0); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _focusedPingLocation != null) { + _fitCameraToPoints([ + pingLocation, + ...located.map((r) => LatLng(r.repeater.lat, r.repeater.lon)), + ]); + } + }); + + // Once the 500ms zoom-to-fit animation settles, run the deferred focus + // line and distance label sync, then reflow labels for collisions. + final appState = context.read(); + Future.delayed(const Duration(milliseconds: 600), () async { + if (!mounted || _focusedPingLocation == null) return; + _focusSyncDeferred = false; + await _updateFocusLines(); + await _syncDistanceLabels(appState); + _reflowDistanceLabelsForCollisions(); + }); + } + + /// Dismiss ping focus mode — restore map state. + void _dismissPingFocus() { + // Key off the SOURCE, not the location: a "missed" ping has a source but no + // focus location, and must still be torn down (clear isFocusModeActive so the + // control bar returns, clear the pill state, etc.). + if (_focusedPingSource == null || !mounted) return; + + context.read().isFocusModeActive = false; + + // Clear focus state first so _anyFocusViewActive sees ping focus gone. + setState(() { + _focusedPingLocation = null; + _focusedPingTimestamp = null; + _focusedRepeaters = []; + _focusPanelMinimized = false; + _focusedPingSource = null; + }); + + // Restore the MeshMapper coverage raster overlay opacity. Safe if the + // layer was hidden via the toggle during focus — setLayerProperties is + // wrapped in try/catch inside the helper. + final appState = context.read(); + _applyCoverageOverlayOpacity(appState.preferences.coverageOverlayOpacity); + + // Restore the shared focus camera (center/zoom + follow/rotation) now that no + // focus view remains. A no-op if a community view is somehow still open + // (can't happen — ping focus tears them down on entry). + _exitFocusCameraIfDone(); + } + + /// Collapse a just-tapped ping to the minimized 2-row pill — the DEFAULT for a + /// ping tap (parity with the cell/repeater tap flow). Run right after + /// [_activatePingFocus]: for a HEARD ping that engaged focus this just flips the + /// panel to minimized; for a MISSED ping (no focus location) it also sets + /// isFocusModeActive so the control bar hides under the pill — without any of + /// the focus VISUALS (those key off _focusedPingLocation, which stays null). + void _minimizePingFocus(DateTime timestamp) { + context.read().isFocusModeActive = true; + setState(() { + _focusedPingTimestamp = timestamp; + _focusPanelMinimized = true; + }); + } + + void _reshowFocusPanel() { + setState(() => _focusPanelMinimized = false); + final source = _focusedPingSource; + if (source is TxPing) { + _showTxPingDetails(source, fromMinimized: true); + } else if (source is RxPing) { + _showRxPingDetails(source, fromMinimized: true); + } else if (source is DiscLogEntry) { + _showDiscPingDetails(source, fromMinimized: true); + } else if (source is TraceLogEntry) { + _showTraceDetails(source, fromMinimized: true); + } + } + + /// Show the minimized info pill and hide the control panel (the focus/history + /// pills do the same — they share the bottom area). [infoPopupMinimized] zeroes + /// the map's bottom padding and removes the control panel in home_screen. + void _setMinimizedInfoPopup(_MinimizedInfoPopup popup) { + setState(() => _minimizedInfoPopup = popup); + context.read().infoPopupMinimized = true; + } + + /// Hide the minimized info pill and restore the control panel. + void _clearMinimizedInfoPopup() { + if (_minimizedInfoPopup == null) return; + setState(() => _minimizedInfoPopup = null); + context.read().infoPopupMinimized = false; + } + + /// Pill for a minimized cell-summary / repeater-detail popup (parity with + /// [_buildMinimizedFocusPanel]). Tap (or the up-arrow) re-opens it; the X + /// closes it and runs its cleanup. + /// A minimized-pill control (↑ expand / ✕ close) with a generous ~44px hit + /// target so it's hard to mis-tap. Opaque so the tap never falls through to the + /// map (or the pill body's tap-swallow). + Widget _pillIconButton(IconData icon, VoidCallback onTap) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: SizedBox( + width: 44, + height: 44, + child: Icon(icon, + size: 22, color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ); + } + + Widget _buildMinimizedInfoPill(_MinimizedInfoPopup popup) { + final theme = Theme.of(context); + return GestureDetector( + // Swallow body taps: tapping the pill body must NOT expand (too easy to hit + // when reaching for close) and must NOT fall through to the map. Expand is + // only via the ↑ button; close only via the ✕ button. + behavior: HitTestBehavior.opaque, + onTap: () {}, + child: Container( + padding: const EdgeInsets.fromLTRB(12, 2, 4, 8), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.5), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Row 1: identity + expand/close controls (each ~44px hit target). + Row( + children: [ + Expanded(child: popup.title), + const SizedBox(width: 4), + _pillIconButton(Icons.keyboard_arrow_up, popup.onReshow), + _pillIconButton(Icons.close, popup.onClose), + ], + ), + // Row 2: stat chips (wraps if the row is too narrow). + popup.statsBuilder(context), + ], + ), + ), + ); + } + + /// One icon+value stat chip for the minimized pill's row 2. [iconColor] tints + /// the icon; [valueColor] tints the value (defaults to onSurface). + Widget _pillStat(IconData icon, String value, + {Color? iconColor, Color? valueColor}) { + final theme = Theme.of(context); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, + size: 14, color: iconColor ?? theme.colorScheme.onSurfaceVariant), + const SizedBox(width: 3), + Text( + value, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: valueColor ?? theme.colorScheme.onSurface, + ), + ), + ], + ); + } + + // Web GRID SUMMARY / repeater palette colours reused for the pill chips. + static const Color _pillGood = Color(0xFF1E7E34); + static const Color _pillBad = Color(0xFFBD2130); + static const Color _pillDistBlue = Color(0xFF007BFF); + + /// Row-1 identity for the cell pill: grid icon + "Grid Summary". + Widget _cellPillTitle() { + final theme = Theme.of(context); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.grid_on, size: 16, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Text('Grid Summary', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface)), + ], + ); + } + + /// Row-2 stats for the cell pill: Max Dist · Avg SNR · Avg Noise · Total Pings. + Widget _cellPillStats(BuildContext context, + Future summaryFuture, bool isImperial) { + return FutureBuilder( + future: summaryFuture, + builder: (context, snap) { + final loading = snap.connectionState != ConnectionState.done; + final s = snap.data; + final dist = loading + ? '…' + : (s?.maxDistMeters != null + ? formatCoverageDistance(s!.maxDistMeters!, + isImperial: isImperial) + : 'N/A'); + final noise = loading + ? '…' + : (s?.avgNoise != null ? '${s!.avgNoise} dBm' : 'N/A'); + final total = loading ? '…' : '${s?.total ?? 0}'; + return Wrap( + spacing: 14, + runSpacing: 6, + children: [ + _pillStat(Icons.straighten, dist, iconColor: _pillDistBlue), + _snrPillStat(loading ? null : s), + _pillStat(Icons.graphic_eq, noise), + _pillStat(Icons.tag, total), + ], + ); + }, + ); + } + + /// Avg-SNR pill chip — a signal-bar icon coloured by quality bucket + the + /// averaged value (mirrors CellSummarySheet._snrCell). [s] null = still loading. + Widget _snrPillStat(GridSummary? s) { + final bucket = s?.snrBucket; + if (s == null || bucket == null || s.avgSnr == null) { + return _pillStat(Icons.signal_cellular_alt, s == null ? '…' : 'N/A'); + } + final IconData icon; + final Color color; + switch (bucket) { + case 'good': + icon = Icons.signal_cellular_4_bar; + color = _pillGood; + break; + case 'medium': + icon = Icons.signal_cellular_alt; + color = const Color(0xFF856404); + break; + default: + icon = Icons.signal_cellular_alt_1_bar; + color = _pillBad; + } + return _pillStat(icon, s.avgSnr!.toStringAsFixed(1), + iconColor: color, valueColor: color); + } + + /// Row-1 identity for the repeater pill: a status-coloured dot + the name. + Widget _repeaterPillTitle(Repeater repeater, Color statusColor) { + final theme = Theme.of(context); + return Row( + children: [ + // Status as a coloured tower (green = online, grey = stale/offline, plus + // the new/ambiguous palette colours). Carries the status that used to be + // a word in the stats row, so that row now fits on a single line. + Icon(Icons.cell_tower, size: 16, color: statusColor), + const SizedBox(width: 8), + Flexible( + child: Text( + repeater.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface), ), ), - ); - }).toList(); + ], + ); } - List _buildRxMarkers(List pings) { - return pings.map((ping) { - // Use blue to match the RX chip in status bar - const color = Colors.blue; + /// Row-2 stats for the repeater pill: Max Range · Hop Bytes · Clock Sync · + /// Last Heard. (Online/offline status is the coloured tower in the title.) + /// Max Range comes from the lazy [statsFuture]. + Widget _repeaterPillStats(BuildContext context, Repeater repeater, + Future statsFuture, String? clockSkew, bool isImperial) { + final clockOk = clockSkew == null; + final lastHeard = + repeater.lastHeard > 0 ? daysAgo(repeater.lastHeard) : 'N/A'; + return FutureBuilder( + future: statsFuture, + builder: (context, snap) { + final loading = snap.connectionState != ConnectionState.done; + final stats = snap.data; + // '?' rather than 'N/A' when the fetch could not be answered: 'N/A' + // means we asked and there is nothing. Tapping the pill opens the sheet, + // which explains it properly. + final String range; + if (loading) { + range = '…'; + } else if (stats == null) { + range = '?'; + } else if (stats.maxRangeMeters != null) { + range = formatCoverageDistance(stats.maxRangeMeters!, + isImperial: isImperial); + } else { + range = 'N/A'; + } + return Wrap( + spacing: 14, + runSpacing: 6, + children: [ + _pillStat(Icons.open_in_full, range, iconColor: _pillDistBlue), + _pillStat(Icons.swap_horiz, '${repeater.hopBytes}B'), + _pillStat(Icons.schedule, clockOk ? 'Synced' : 'Off', + iconColor: clockOk ? _pillGood : _pillBad, + valueColor: clockOk ? _pillGood : _pillBad), + _pillStat(Icons.history, lastHeard), + ], + ); + }, + ); + } - return Marker( - point: LatLng(ping.latitude, ping.longitude), - width: 20, - height: 20, - child: GestureDetector( - onTap: () => _showRxPingDetails(ping), - child: Container( - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), - // Simple dot - no arrow (looks good at any map rotation) - ), - ), - ); - }).toList(); + /// Show the cell GRID SUMMARY as a minimized pill (the default a tile tap + /// opens in). Tapping it expands to the full sheet; closing clears the + /// footprint. Reused on the initial tap and when the sheet is minimized. + void _minimizeCellSummary({ + required GridCell cell, + required int blob, + required Future summaryFuture, + required bool isImperial, + }) { + _setMinimizedInfoPopup(_MinimizedInfoPopup( + title: _cellPillTitle(), + statsBuilder: (ctx) => _cellPillStats(ctx, summaryFuture, isImperial), + onReshow: () { + _clearMinimizedInfoPopup(); + _presentCellSummarySheet( + cell: cell, + blob: blob, + summaryFuture: summaryFuture, + isImperial: isImperial, + ); + }, + onClose: () { + _clearMinimizedInfoPopup(); + _clearCellHighlight(); + }, + )); } - List _buildDiscMarkers(List entries, bool discDropEnabled) { - return entries.map((entry) { - return Marker( - point: LatLng(entry.latitude, entry.longitude), - width: 20, - height: 20, - child: GestureDetector( - onTap: () => _showDiscPingDetails(entry), - child: Container( - decoration: BoxDecoration( - color: entry.nodeCount == 0 - ? (discDropEnabled ? Colors.red : Colors.grey) - : _discMarkerColor, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), + Widget _buildMinimizedFocusPanel() { + final source = _focusedPingSource; + String title; + IconData icon; + Color color; + if (source is TxPing) { + title = 'TX Ping'; + icon = Icons.arrow_upward; + color = PingColors.txSuccess; + } else if (source is RxPing) { + title = 'RX Ping'; + icon = Icons.arrow_downward; + color = PingColors.rx; + } else if (source is DiscLogEntry) { + title = 'Disc Request'; + icon = Icons.radar; + color = PingColors.discSuccess; + } else if (source is TraceLogEntry) { + title = 'Trace'; + icon = Icons.gps_fixed; + color = Colors.cyan; + } else { + return const SizedBox.shrink(); + } + + final theme = Theme.of(context); + final timeStr = _focusedPingTimestamp != null + ? _formatTime(_focusedPingTimestamp!) + : ''; + + return GestureDetector( + // Swallow body taps (no accidental expand / no fall-through to the map). + // Expand only via ↑, close only via ✕ — parity with _buildMinimizedInfoPill. + behavior: HitTestBehavior.opaque, + onTap: () {}, + child: Container( + padding: const EdgeInsets.fromLTRB(12, 2, 4, 8), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.5), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Row 1: type icon + title + timestamp + expand/close (each ~44px hit). + Row( + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 8), + Text( + title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + timeStr, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), ), + _pillIconButton(Icons.keyboard_arrow_up, _reshowFocusPanel), + _pillIconButton(Icons.close, _dismissPingFocus), ], ), - ), + // Row 2: summary stat chips computed synchronously from the source. + _buildFocusPingStats(source), + ], ), - ); - }).toList(); + ), + ); } - /// DISC marker color (#7B68EE - medium slate blue/purple) - static const Color _discMarkerColor = Color(0xFF7B68EE); - - /// Repeater marker color (#a52163 - magenta/pink) - Active - static const Color _repeaterMarkerColor = Color(0xFFA52163); - - /// Duplicate repeater marker color (#a51d2a - red) - static const Color _repeaterDuplicateColor = Color(0xFFA51D2A); + /// Row-2 summary chips for the minimized ping pill, computed synchronously from + /// the focused ping [source] (parity with the cell/repeater pill's stats row). + /// Best-of (max) SNR/RSSI across all heard repeaters for multi-repeater TX/DISC; + /// the single value for RX/TRACE. "Missed" pings show a short miss label. + Widget _buildFocusPingStats(Object source) { + final missColor = Theme.of(context).colorScheme.onSurfaceVariant; + final chips = []; + + Widget snrChip(double snr) => _pillStat( + Icons.signal_cellular_alt, + '${snr.toStringAsFixed(1)} SNR', + iconColor: PingColors.snrColor(snr), + valueColor: PingColors.snrColor(snr), + ); + Widget rssiChip(int rssi) => _pillStat( + Icons.cell_tower, + '$rssi dBm', + iconColor: PingColors.rssiColor(rssi), + valueColor: PingColors.rssiColor(rssi), + ); + + if (source is TxPing) { + final reps = source.heardRepeaters; + if (reps.isEmpty) { + chips.add(_pillStat(Icons.close, 'Not heard', iconColor: missColor)); + } else { + final direct = reps.where((r) => r.pathHops == null).length; + final multi = reps.length - direct; + if (direct > 0) { + chips.add(_pillStat(Icons.arrow_upward, '$direct direct')); + } + if (multi > 0) { + chips.add(_pillStat(Icons.alt_route, '$multi multi')); + } + final snrs = reps.map((r) => r.snr).whereType(); + if (snrs.isNotEmpty) chips.add(snrChip(snrs.reduce(math.max))); + final rssis = reps.map((r) => r.rssi).whereType(); + if (rssis.isNotEmpty) chips.add(rssiChip(rssis.reduce(math.max))); + } + } else if (source is RxPing) { + chips.add(snrChip(source.snr)); + chips.add(rssiChip(source.rssi)); + if (source.pathHops.isNotEmpty) { + chips.add(_pillStat(Icons.alt_route, '${source.pathHops.length} hops')); + } + } else if (source is DiscLogEntry) { + final nodes = source.discoveredNodes; + if (nodes.isEmpty) { + chips.add(_pillStat(Icons.close, 'No response', iconColor: missColor)); + } else { + chips.add(_pillStat(Icons.radar, + '${nodes.length} node${nodes.length != 1 ? 's' : ''}')); + chips.add(snrChip(nodes.map((n) => n.localSnr).reduce(math.max))); + chips.add(rssiChip(nodes.map((n) => n.localRssi).reduce(math.max))); + } + } else if (source is TraceLogEntry) { + if (!source.success) { + chips.add(_pillStat(Icons.close, 'No response', iconColor: missColor)); + } else { + chips.add(_pillStat(Icons.check_circle, 'Success', + iconColor: PingColors.txSuccess)); + if (source.localSnr != null) chips.add(snrChip(source.localSnr!)); + if (source.remoteSnr != null) { + chips.add(_pillStat(Icons.arrow_upward, + 'TX ${source.remoteSnr!.toStringAsFixed(1)}')); + } + if (source.localRssi != null) chips.add(rssiChip(source.localRssi!)); + } + } - /// New repeater marker color (#c05802 - orange) - static const Color _repeaterNewColor = Color(0xFFC05802); + if (chips.isEmpty) return const SizedBox.shrink(); + return Wrap(spacing: 14, runSpacing: 6, children: chips); + } - /// Dead repeater marker color (grey) - static const Color _repeaterDeadColor = Colors.grey; + void _showHistoryMarkerAsLive(PingEventMarker marker) { + if (marker.latitude == null || marker.longitude == null) return; + final lat = marker.latitude!; + final lon = marker.longitude!; + final repeaters = marker.repeaters ?? []; + + switch (marker.type) { + case PingEventType.txSuccess: + case PingEventType.txFail: + case PingEventType.txMultiHopOnly: + _showTxPingDetails(TxPing( + latitude: lat, + longitude: lon, + timestamp: marker.timestamp, + power: 0, + deviceId: '', + heardRepeaters: repeaters + .map((r) => HeardRepeater( + repeaterId: r.repeaterId, + snr: r.snr, + rssi: r.rssi, + pathHops: r.pathHops, + )) + .toList(), + )); + case PingEventType.rx: + final r = repeaters.isNotEmpty ? repeaters.first : null; + _showRxPingDetails(RxPing( + latitude: lat, + longitude: lon, + timestamp: marker.timestamp, + repeaterId: r?.repeaterId ?? '', + snr: r?.snr ?? 0, + rssi: r?.rssi ?? 0, + )); + case PingEventType.discSuccess: + case PingEventType.discFail: + _showDiscPingDetails(DiscLogEntry( + timestamp: marker.timestamp, + latitude: lat, + longitude: lon, + noiseFloor: marker.noiseFloor, + discoveredNodes: repeaters + .map((r) => DiscoveredNodeEntry( + repeaterId: r.repeaterId, + nodeType: 'REPEATER', + localSnr: r.snr, + localRssi: r.rssi, + remoteSnr: 0, + pubkeyHex: r.pubkeyHex, + )) + .toList(), + )); + case PingEventType.traceSuccess: + case PingEventType.traceFail: + final r = repeaters.isNotEmpty ? repeaters.first : null; + _showTraceDetails(TraceLogEntry( + timestamp: marker.timestamp, + latitude: lat, + longitude: lon, + targetRepeaterId: r?.repeaterId ?? '', + noiseFloor: marker.noiseFloor, + localSnr: r?.snr, + localRssi: r?.rssi, + success: marker.type == PingEventType.traceSuccess, + )); + } + } - /// Get set of duplicate repeater IDs Set _getDuplicateRepeaterIds(List repeaters) { final idCounts = {}; for (final repeater in repeaters) { idCounts[repeater.id] = (idCounts[repeater.id] ?? 0) + 1; } - return idCounts.entries - .where((e) => e.value > 1) - .map((e) => e.key) - .toSet(); + return idCounts.entries.where((e) => e.value > 1).map((e) => e.key).toSet(); } + /// Repeaters eligible for map rendering — excludes anything not heard in + /// the past 30 days so long-stale entries don't appear, contribute to + /// clusters, or get pulled into spider expansions. All map-rendering + /// paths route through this; non-map consumers (log, picker) keep using + /// `appState.repeaters` directly. + List _mapVisibleRepeaters(AppStateProvider appState) => + appState.repeaters.where((r) => r.isHeardRecently).toList(); + /// Get marker color for a repeater based on status priority: /// 1. Duplicate → Red (always takes priority) /// 2. Dead → Grey (not heard in 24 hours) @@ -1638,88 +8186,131 @@ class _MapWidgetState extends State with TickerProviderStateMixin { return _repeaterMarkerColor; // Active (default) } - List _buildRepeaterMarkers(List repeaters, int? regionHopBytesOverride) { - final duplicateIds = _getDuplicateRepeaterIds(repeaters); + /// Compute node column width based on hop byte count. + /// [extraPadding] adds space for additional content (e.g. nodeTypeLabel in DISC popup). + double _nodeColumnWidth({double extraPadding = 0}) { + final appState = context.read(); + final hopBytes = appState.enforceHopBytes + ? appState.effectiveHopBytes + : appState.hopBytes; + switch (hopBytes) { + case 2: + return 70 + extraPadding; + case 3: + // 3-byte (6-char) hex needs extra room or the last digit clips (#383) + return 88 + extraPadding; + default: + return 60 + extraPadding; + } + } - return repeaters.map((repeater) { - final isDuplicate = duplicateIds.contains(repeater.id); - final markerColor = _getRepeaterMarkerColor(repeater, isDuplicate); + /// Show all ambiguous (duplicate-ID) repeaters in a dialog. + void _showDuplicateRepeaterPopup( + List<_ResolvedRepeater> resolved, { + ({double lat, double lon})? fromLatLng, + }) { + final ambiguous = resolved.where((r) => r.ambiguous).toList(); + if (ambiguous.isEmpty) return; - // Display hex ID based on per-repeater hop_bytes (or regional admin override) - final displayId = repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); - final effectiveBytes = regionHopBytesOverride ?? repeater.hopBytes; - final isLongId = displayId.length > 2; - final markerWidth = displayId.length > 4 ? 48.0 : isLongId ? 40.0 : 28.0; + final appState = context.read(); + final regionOverride = + appState.enforceHopBytes ? appState.effectiveHopBytes : null; - // Shape varies by hop bytes: 1=square, 2=rounded rect, 3=more rounded - final borderRadius = effectiveBytes >= 3 - ? BorderRadius.circular(8) - : effectiveBytes == 2 - ? BorderRadius.circular(6) - : BorderRadius.circular(4); - - return Marker( - point: LatLng(repeater.lat, repeater.lon), - width: markerWidth, - height: 28, - child: GestureDetector( - onTap: () => _showRepeaterDetails(repeater, isDuplicate: isDuplicate, regionHopBytesOverride: regionHopBytesOverride), - child: Container( - padding: isLongId - ? const EdgeInsets.symmetric(horizontal: 4) - : EdgeInsets.zero, - decoration: BoxDecoration( - color: markerColor, - borderRadius: borderRadius, - border: Border.all(color: Colors.white, width: 2), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), + showDialog( + context: context, + builder: (dialogContext) => Dialog( + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), + ), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.warning_amber_rounded, + size: 18, color: Color(0xFFF59E0B)), + const SizedBox(width: 8), + Text( + 'Duplicate Repeater IDs', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ], + ), + const SizedBox(height: 12), + Divider(height: 1, color: Theme.of(context).dividerColor), + const SizedBox(height: 8), + Text( + 'Multiple repeaters share the same short ID. ' + 'We can\'t determine which one heard your ping.', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), + const SizedBox(height: 8), + ...ambiguous.map((r) => RepeaterIdChip.buildRepeaterRow( + context, + r.repeater, + refLat: fromLatLng?.lat, + refLon: fromLatLng?.lon, + regionHopBytesOverride: regionOverride, + )), ], ), - alignment: Alignment.center, - child: Text( - displayId, - style: TextStyle( - fontSize: displayId.length > 4 ? 8 : isLongId ? 9 : 10, - fontWeight: FontWeight.bold, - color: Colors.white, - fontFamily: 'monospace', - ), - ), ), ), - ); - }).toList(); - } - - Widget _buildCurrentPositionMarker(double heading) { - // Convert heading from degrees to radians - // heading is 0-360 degrees, 0 = North, 90 = East - final headingRadians = heading * (math.pi / 180); - - // Clean directional arrow - return Transform.rotate( - angle: headingRadians, - child: CustomPaint( - size: const Size(24, 24), - painter: _ArrowPainter(), ), ); } /// Show TX ping details popup - void _showTxPingDetails(TxPing ping) { + void _showTxPingDetails(TxPing ping, {bool fromMinimized = false}) { // Use the heardRepeaters directly from the TxPing final heardRepeaters = ping.heardRepeaters; + // Resolve all repeaters (direct + multi-hop) for focus-mode lines + final resolved = heardRepeaters.isNotEmpty + ? _resolveRepeatersByHexIds( + heardRepeaters.map((r) => r.repeaterId).toList(), + snrValues: heardRepeaters.map((r) => r.snr).toList(), + ) + : <_ResolvedRepeater>[]; + final hasAmbiguous = resolved.any((r) => r.ambiguous); + + // Default a real tap to the minimized 2-row pill — the full sheet opens only + // on expand (fromMinimized: true, via _reshowFocusPanel). _activatePingFocus + // engages focus when the ping was heard, or clears any prior focus / open + // community view when it wasn't; set _focusedPingSource AFTER it (its missed + // branch dismisses a prior ping, which would otherwise clear a source set + // earlier). + if (!fromMinimized) { + _activatePingFocus( + LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); + _focusedPingSource = ping; + _minimizePingFocus(ping.timestamp); + return; + } + showModalBottomSheet( context: context, useSafeArea: true, isScrollControlled: true, + // Transparent barrier so the map stays fully bright during focus mode — + // the default Colors.black54 scrim would defeat the purpose of focus. + barrierColor: Colors.transparent, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), @@ -1730,7 +8321,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), child: SingleChildScrollView( child: Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -1742,11 +8334,13 @@ class _MapWidgetState extends State with TickerProviderStateMixin { Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.15), + color: PingColors.txSuccess.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.green.withValues(alpha: 0.4)), + border: Border.all( + color: PingColors.txSuccess.withValues(alpha: 0.4)), ), - child: const Icon(Icons.arrow_upward, color: Colors.green, size: 24), + child: Icon(Icons.arrow_upward, + color: PingColors.txSuccess, size: 24), ), const SizedBox(width: 12), Expanded( @@ -1755,20 +8349,33 @@ class _MapWidgetState extends State with TickerProviderStateMixin { children: [ Text( 'TX Ping', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.w600, + ), ), Text( _formatTime(ping.timestamp), style: TextStyle( fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ], ), ), + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: () => Navigator.pop(context, 'minimized'), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Minimize', + ), + const SizedBox(width: 4), IconButton( icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(context), @@ -1781,15 +8388,23 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Location chip Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -1806,170 +8421,418 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), const SizedBox(height: 16), - // Repeaters section header - Text( - heardRepeaters.isEmpty ? 'No repeaters heard' : 'Heard Repeaters (${heardRepeaters.length})', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurfaceVariant, - letterSpacing: 0.5, - ), + // Split repeaters into direct and multi-hop + ..._buildTxRepeaterSections( + context, ping, heardRepeaters, resolved, hasAmbiguous), + ], + ), + ), + ), + ), + ).then((result) { + if (result == 'minimized') { + setState(() => _focusPanelMinimized = true); + } else { + _dismissPingFocus(); + } + }); + } + + List _buildTxRepeaterSections( + BuildContext context, + TxPing ping, + List allRepeaters, + List<_ResolvedRepeater> resolved, + bool hasAmbiguous, + ) { + final directRepeaters = + allRepeaters.where((r) => r.pathHops == null).toList(); + final multiHopRepeaters = + allRepeaters.where((r) => r.pathHops != null).toList(); + + if (directRepeaters.isEmpty && multiHopRepeaters.isEmpty) { + return [ + Text( + 'No repeaters heard', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.5, + ), + ), + ]; + } + + final widgets = []; + + // --- Direct echoes section --- + widgets.add(Text( + directRepeaters.isEmpty + ? 'No direct repeats heard' + : 'Direct Repeats (${directRepeaters.length})', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.5, + fontStyle: + directRepeaters.isEmpty ? FontStyle.italic : FontStyle.normal, + ), + )); + + if (hasAmbiguous) { + widgets.add(GestureDetector( + onTap: () => _showDuplicateRepeaterPopup( + resolved, + fromLatLng: (lat: ping.latitude, lon: ping.longitude), + ), + child: const Padding( + padding: EdgeInsets.only(top: 6), + child: Row( + children: [ + Icon(Icons.warning_amber_rounded, + size: 14, color: Color(0xFFF59E0B)), + SizedBox(width: 4), + Flexible( + child: Text( + 'Duplicate repeater ID — lines shown to all possible matches', + style: TextStyle(fontSize: 11, color: Color(0xFFF59E0B)), ), + ), + SizedBox(width: 4), + Icon(Icons.info_outline, size: 14, color: Color(0xFFF59E0B)), + ], + ), + ), + )); + } - if (heardRepeaters.isNotEmpty) ...[ - const SizedBox(height: 12), - // Repeaters table - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + if (directRepeaters.isNotEmpty) { + widgets.add(const SizedBox(height: 12)); + widgets.add(_buildRepeaterTable(context, ping, directRepeaters)); + } + + // --- Multi-hop echoes section --- + if (multiHopRepeaters.isNotEmpty) { + widgets.add(const SizedBox(height: 16)); + widgets.add(Row( + children: [ + Icon(Icons.route, size: 14, color: PingColors.rx), + const SizedBox(width: 6), + Text( + 'Multi-hop Repeats (${multiHopRepeaters.length})', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.5, + ), + ), + ], + )); + widgets.add(const SizedBox(height: 12)); + widgets + .add(_buildMultiHopRepeaterTable(context, ping, multiHopRepeaters)); + } + + return widgets; + } + + Widget _buildRepeaterTable( + BuildContext context, TxPing ping, List repeaters) { + final chipWidth = _nodeColumnWidth(); + final anyLacksLocation = + repeaters.any((hr) => _hexIdLacksLocation(hr.repeaterId)); + final iconReserve = anyLacksLocation ? 18.0 : 0.0; + final nodeColWidth = chipWidth + iconReserve; + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + SizedBox( + width: nodeColWidth, + child: Text('Node', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: + Theme.of(context).colorScheme.onSurfaceVariant)), + ), + Expanded( + child: Text('SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant))), + Expanded( + child: Text('RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant))), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + ...repeaters.map((repeater) { + final snrColor = repeater.snr != null + ? PingColors.snrColor(repeater.snr!) + : Colors.grey; + final rssiColor = repeater.rssi != null + ? PingColors.rssiColor(repeater.rssi!) + : Colors.grey; + final lacksLocation = _hexIdLacksLocation(repeater.repeaterId); + + return InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, repeater.repeaterId, + fromLatLng: (lat: ping.latitude, lon: ping.longitude)), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + SizedBox( + width: nodeColWidth, + child: Row( + children: [ + RepeaterIdChip( + repeaterId: repeater.repeaterId, + fontSize: 13, + width: chipWidth), + if (lacksLocation) + Padding( + padding: const EdgeInsets.only(left: 4), + child: _noLocationIndicator(), + ), + ], + ), ), - child: Column( + Expanded( + child: Center( + child: _buildStatChip( + value: repeater.snr?.toStringAsFixed(1) ?? '-', + color: snrColor, + ), + ), + ), + Expanded( + child: Center( + child: _buildStatChip( + value: + repeater.rssi != null ? '${repeater.rssi}' : '-', + color: rssiColor, + ), + ), + ), + ], + ), + ), + ); + }), + ], + ), + ); + } + + Widget _buildMultiHopRepeaterTable( + BuildContext context, TxPing ping, List repeaters) { + final chipWidth = _nodeColumnWidth(); + final anyLacksLocation = + repeaters.any((hr) => _hexIdLacksLocation(hr.repeaterId)); + final iconReserve = anyLacksLocation ? 18.0 : 0.0; + final nodeColWidth = chipWidth + iconReserve; + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + SizedBox( + width: nodeColWidth, + child: Text('Node', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: + Theme.of(context).colorScheme.onSurfaceVariant)), + ), + Expanded( + child: Text('SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant))), + Expanded( + child: Text('RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant))), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + ...repeaters.map((repeater) { + final snrColor = repeater.snr != null + ? PingColors.snrColor(repeater.snr!) + : Colors.grey; + final rssiColor = repeater.rssi != null + ? PingColors.rssiColor(repeater.rssi!) + : Colors.grey; + final lacksLocation = _hexIdLacksLocation(repeater.repeaterId); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, repeater.repeaterId, + fromLatLng: (lat: ping.latitude, lon: ping.longitude)), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + SizedBox( + width: nodeColWidth, child: Row( children: [ - SizedBox( - width: 60, - child: Text( - 'Node', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + RepeaterIdChip( + repeaterId: repeater.repeaterId, + fontSize: 13, + width: chipWidth), + if (lacksLocation) + Padding( + padding: const EdgeInsets.only(left: 4), + child: _noLocationIndicator(), ), - ), - Expanded( - child: Text( - 'SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), ], ), ), - Divider(height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...heardRepeaters.map((repeater) { - // Calculate SNR chip color - Color snrColor; - if (repeater.snr == null) { - snrColor = Colors.grey; - } else if (repeater.snr! <= -1) { - snrColor = Colors.red; - } else if (repeater.snr! <= 5) { - snrColor = Colors.orange; - } else { - snrColor = Colors.green; - } - - // Calculate RSSI chip color - Color rssiColor; - if (repeater.rssi == null) { - rssiColor = Colors.grey; - } else if (repeater.rssi! >= -70) { - rssiColor = Colors.green; - } else if (repeater.rssi! >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } - - return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - children: [ - // Repeater ID - RepeaterIdChip(repeaterId: repeater.repeaterId, fontSize: 13, width: 60), - // SNR - Expanded( - child: Center( - child: _buildStatChip( - value: repeater.snr?.toStringAsFixed(1) ?? '-', - color: snrColor, - ), - ), - ), - // RSSI - Expanded( - child: Center( - child: _buildStatChip( - value: repeater.rssi != null ? '${repeater.rssi}' : '-', - color: rssiColor, - ), - ), - ), - ], - ), - ), - ); - }), + Expanded( + child: Center( + child: _buildStatChip( + value: repeater.snr?.toStringAsFixed(1) ?? '-', + color: snrColor, + ), + ), + ), + Expanded( + child: Center( + child: _buildStatChip( + value: repeater.rssi != null + ? '${repeater.rssi}' + : '-', + color: rssiColor, + ), + ), + ), + ], + ), + ), + ), + if (repeater.pathHops != null && repeater.pathHops!.isNotEmpty) + Padding( + padding: + const EdgeInsets.only(left: 12, right: 12, bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon(Icons.route, + size: 12, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant), + ), + const SizedBox(width: 6), + Expanded( + child: RxPathChain( + hops: repeater.pathHops!, fontSize: 11), + ), ], ), ), - ], ], - ), - ), - ), + ); + }), + ], ), ); } /// Show RX ping details popup - void _showRxPingDetails(RxPing ping) { - // Calculate SNR severity for chip color - Color snrColor; - if (ping.snr <= -1) { - snrColor = Colors.red; - } else if (ping.snr <= 5) { - snrColor = Colors.orange; - } else { - snrColor = Colors.green; - } + void _showRxPingDetails(RxPing ping, {bool fromMinimized = false}) { + final snrColor = PingColors.snrColor(ping.snr); + final rssiColor = PingColors.rssiColor(ping.rssi); + + // Activate focus mode for the RX ping's repeater + final resolved = _resolveRepeatersByHexIds( + [ping.repeaterId], + snrValues: [ping.snr], + ); - // Calculate RSSI chip color based on signal strength - Color rssiColor; - if (ping.rssi >= -70) { - rssiColor = Colors.green; // Strong: -30 to -70 dBm - } else if (ping.rssi >= -100) { - rssiColor = Colors.orange; // Medium: -70 to -100 dBm - } else { - rssiColor = Colors.red; // Weak: -100 to -120 dBm + // Default a real tap to the minimized 2-row pill — the full sheet opens only + // on expand (fromMinimized: true). Set _focusedPingSource AFTER + // _activatePingFocus (its missed branch dismisses a prior ping, clearing the + // source). RX is always a reception, so it always has located repeater data. + if (!fromMinimized) { + _activatePingFocus( + LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); + _focusedPingSource = ping; + _minimizePingFocus(ping.timestamp); + return; } showModalBottomSheet( context: context, useSafeArea: true, + // Transparent barrier so the map stays fully bright during focus mode — + // the default Colors.black54 scrim would defeat the purpose of focus. + barrierColor: Colors.transparent, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -1981,11 +8844,13 @@ class _MapWidgetState extends State with TickerProviderStateMixin { Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.15), + color: PingColors.rx.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.blue.withValues(alpha: 0.4)), + border: + Border.all(color: PingColors.rx.withValues(alpha: 0.4)), ), - child: const Icon(Icons.arrow_downward, color: Colors.blue, size: 24), + child: Icon(Icons.arrow_downward, + color: PingColors.rx, size: 24), ), const SizedBox(width: 12), Expanded( @@ -1995,8 +8860,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { Text( 'RX Ping', style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), Text( _formatTime(ping.timestamp), @@ -2008,6 +8873,14 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ], ), ), + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: () => Navigator.pop(context, 'minimized'), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Minimize', + ), + const SizedBox(width: 4), IconButton( icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(context), @@ -2024,11 +8897,17 @@ class _MapWidgetState extends State with TickerProviderStateMixin { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -2057,103 +8936,209 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), const SizedBox(height: 12), - // Repeater table (single row) - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), - ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - children: [ - SizedBox( - width: 60, - child: Text( - 'Node', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + // Repeater table (single row). When the repeater has no GPS on + // file, surface a small grey location_off icon next to the chip + // so the user knows the focus map deliberately skipped it. + Builder(builder: (context) { + final chipWidth = _nodeColumnWidth(); + final lacksLocation = _hexIdLacksLocation(ping.repeaterId); + final iconReserve = lacksLocation ? 18.0 : 0.0; + final nodeColWidth = chipWidth + iconReserve; + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), + ), + child: Column( + children: [ + // Header row + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + child: Row( + children: [ + SizedBox( + width: nodeColWidth, + child: Text( + 'Node', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), ), ), - ), - Expanded( - child: Text( - 'SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + Expanded( + child: Text( + 'SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), ), ), - ), - Expanded( - child: Text( - 'RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + Expanded( + child: Text( + 'RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), ), ), - ), - ], + ], + ), ), - ), - Divider(height: 1, color: Theme.of(context).dividerColor), - // Data row - InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, ping.repeaterId), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - children: [ - // Repeater ID - RepeaterIdChip(repeaterId: ping.repeaterId, fontSize: 13, width: 60), - // SNR - Expanded( - child: Center( - child: _buildStatChip( - value: ping.snr.toStringAsFixed(1), - color: snrColor, + Divider(height: 1, color: Theme.of(context).dividerColor), + // Data row + InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, ping.repeaterId, fromLatLng: ( + lat: ping.latitude, + lon: ping.longitude + )), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + child: Row( + children: [ + // Repeater ID + optional no-location icon, pinned + // to the node column width so SNR/RSSI stay aligned. + SizedBox( + width: nodeColWidth, + child: Row( + children: [ + RepeaterIdChip( + repeaterId: ping.repeaterId, + fontSize: 13, + width: chipWidth), + if (lacksLocation) + Padding( + padding: const EdgeInsets.only(left: 4), + child: _noLocationIndicator(), + ), + ], ), ), - ), - // RSSI - Expanded( - child: Center( - child: _buildStatChip( - value: '${ping.rssi}', - color: rssiColor, + // SNR + Expanded( + child: Center( + child: _buildStatChip( + value: ping.snr.toStringAsFixed(1), + color: snrColor, + ), + ), ), - ), + // RSSI + Expanded( + child: Center( + child: _buildStatChip( + value: '${ping.rssi}', + color: rssiColor, + ), + ), + ), + ], ), - ], - ), + ), ), - ), - ], + ], + ), + ); + }), + + // Path section (origin → ... → us). Skipped when the path is + // unavailable, e.g. RxPings reloaded from Hive (transient field). + if (ping.pathHops.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + 'Path', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + letterSpacing: 0.5, + ), ), - ), + const SizedBox(height: 12), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), + ), + child: RxPathChain( + hops: ping.pathHops, + fromLatLng: (lat: ping.latitude, lon: ping.longitude), + fontSize: 13, + ), + ), + ], ], ), ), - ); + ).then((result) { + if (result == 'minimized') { + setState(() => _focusPanelMinimized = true); + } else { + _dismissPingFocus(); + } + }); } /// Show DISC ping details popup - void _showDiscPingDetails(DiscLogEntry entry) { + void _showDiscPingDetails(DiscLogEntry entry, {bool fromMinimized = false}) { + // Default a real tap to the minimized 2-row pill — the full sheet opens only + // on expand (fromMinimized: true). _activatePingFocus engages focus for + // located nodes or clears any prior focus / community view (empty list when + // nothing was discovered); set _focusedPingSource AFTER it (its missed branch + // dismisses a prior ping, which would otherwise clear the source). + if (!fromMinimized) { + final resolved = entry.discoveredNodes.isNotEmpty + ? _resolveRepeatersByHexIds( + entry.discoveredNodes.map((n) => n.repeaterId).toList(), + fullHexIds: + entry.discoveredNodes.map((n) => n.pubkeyHex).toList(), + snrValues: entry.discoveredNodes + .map((n) => n.localSnr as double?) + .toList(), + ) + : const <_ResolvedRepeater>[]; + _activatePingFocus( + LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); + _focusedPingSource = entry; + _minimizePingFocus(entry.timestamp); + return; + } + showModalBottomSheet( context: context, useSafeArea: true, isScrollControlled: true, + // Transparent barrier so the map stays fully bright during focus mode — + // the default Colors.black54 scrim would defeat the purpose of focus. + barrierColor: Colors.transparent, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), @@ -2164,7 +9149,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), child: SingleChildScrollView( child: Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -2178,9 +9164,11 @@ class _MapWidgetState extends State with TickerProviderStateMixin { decoration: BoxDecoration( color: _discMarkerColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: _discMarkerColor.withValues(alpha: 0.4)), + border: Border.all( + color: _discMarkerColor.withValues(alpha: 0.4)), ), - child: const Icon(Icons.radar, color: _discMarkerColor, size: 24), + child: + Icon(Icons.radar, color: _discMarkerColor, size: 24), ), const SizedBox(width: 12), Expanded( @@ -2189,20 +9177,33 @@ class _MapWidgetState extends State with TickerProviderStateMixin { children: [ Text( 'Disc Request', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.w600, + ), ), Text( _formatTime(entry.timestamp), style: TextStyle( fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ], ), ), + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: () => Navigator.pop(context, 'minimized'), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Minimize', + ), + const SizedBox(width: 4), IconButton( icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(context), @@ -2215,15 +9216,23 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Location chip Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), ), child: Row( children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.location_on, + size: 16, + color: + Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -2255,163 +9264,192 @@ class _MapWidgetState extends State with TickerProviderStateMixin { if (entry.discoveredNodes.isNotEmpty) ...[ const SizedBox(height: 12), - // Table with headers - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), - ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - children: [ - SizedBox( - width: 60, - child: Text( - 'Node', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + // Table with headers — reserve a sliver of node-column + // width for the inline `location_off` indicator when any + // discovered node has no GPS on file, so the RX/RSSI/TX + // columns stay aligned row-to-row. + Builder(builder: (context) { + final anyLacksLocation = entry.discoveredNodes + .any((n) => _hexIdLacksLocation(n.repeaterId)); + final nodeExtra = 20.0 + (anyLacksLocation ? 18.0 : 0.0); + return Container( + decoration: BoxDecoration( + color: + Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), + ), + child: Column( + children: [ + // Header row + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + child: Row( + children: [ + SizedBox( + width: + _nodeColumnWidth(extraPadding: nodeExtra), + child: Text( + 'Node', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), ), ), - ), - Expanded( - child: Text( - 'RX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + Expanded( + child: Text( + 'RX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), ), ), - ), - Expanded( - child: Text( - 'RX RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + Expanded( + child: Text( + 'RX RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), ), ), - ), - Expanded( - child: Text( - 'TX SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, + Expanded( + child: Text( + 'TX SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), ), ), - ), - ], + ], + ), ), - ), - Divider(height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...entry.discoveredNodes.map((node) { - // Calculate colors - Color rxSnrColor; - if (node.localSnr <= -1) { - rxSnrColor = Colors.red; - } else if (node.localSnr <= 5) { - rxSnrColor = Colors.orange; - } else { - rxSnrColor = Colors.green; - } - - Color rssiColor; - if (node.localRssi >= -70) { - rssiColor = Colors.green; - } else if (node.localRssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } - - Color txSnrColor; - if (node.remoteSnr <= -1) { - txSnrColor = Colors.red; - } else if (node.remoteSnr <= 5) { - txSnrColor = Colors.orange; - } else { - txSnrColor = Colors.green; - } - - return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - children: [ - // Node ID with type - SizedBox( - width: 60, - child: Row( - children: [ - RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 13), - Text( - node.nodeTypeLabel, - style: const TextStyle( - fontSize: 10, - fontWeight: FontWeight.w500, - color: _discMarkerColor, + Divider( + height: 1, color: Theme.of(context).dividerColor), + // Data rows + ...entry.discoveredNodes.map((node) { + final rxSnrColor = + PingColors.snrColor(node.localSnr); + final rssiColor = + PingColors.rssiColor(node.localRssi); + final txSnrColor = + PingColors.snrColor(node.remoteSnr.toDouble()); + final lacksLocation = + _hexIdLacksLocation(node.repeaterId); + + return InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, node.repeaterId, + fullHexId: node.pubkeyHex, + fromLatLng: ( + lat: entry.latitude, + lon: entry.longitude + )), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + child: Row( + children: [ + // Node ID with type (+ optional no-loc icon) + SizedBox( + width: _nodeColumnWidth( + extraPadding: nodeExtra), + child: Row( + children: [ + RepeaterIdChip( + repeaterId: node.repeaterId, + fontSize: 13), + Text( + node.nodeTypeLabel, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: _discMarkerColor, + ), ), - ), - ], + if (lacksLocation) + Padding( + padding: const EdgeInsets.only( + left: 4), + child: _noLocationIndicator(), + ), + ], + ), ), - ), - // RX SNR - Expanded( - child: Center( - child: _buildStatChip( - value: node.localSnr.toStringAsFixed(1), - color: rxSnrColor, + // RX SNR + Expanded( + child: Center( + child: _buildStatChip( + value: + node.localSnr.toStringAsFixed(1), + color: rxSnrColor, + ), ), ), - ), - // RSSI - Expanded( - child: Center( - child: _buildStatChip( - value: '${node.localRssi}', - color: rssiColor, + // RSSI + Expanded( + child: Center( + child: _buildStatChip( + value: '${node.localRssi}', + color: rssiColor, + ), ), ), - ), - // TX SNR - Expanded( - child: Center( - child: _buildStatChip( - value: node.remoteSnr.toStringAsFixed(1), - color: txSnrColor, + // TX SNR + Expanded( + child: Center( + child: _buildStatChip( + value: + node.remoteSnr.toStringAsFixed(1), + color: txSnrColor, + ), ), ), - ), - ], + ], + ), ), - ), - ); - }), - ], - ), - ), + ); + }), + ], + ), + ); + }), ], ], ), ), ), ), - ); + ).then((result) { + if (result == 'minimized') { + setState(() => _focusPanelMinimized = true); + } else { + _dismissPingFocus(); + } + }); } /// Build a status chip for the repeater popup @@ -2434,151 +9472,543 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } - /// Show repeater details popup - void _showRepeaterDetails(Repeater repeater, {bool isDuplicate = false, int? regionHopBytesOverride}) { + /// Show repeater details. Opens minimized as a stats pill by default; tapping + /// the pill re-enters with [expand] true to show the full detail sheet, and + /// the sheet's minimize re-enters with [expand] false. [cachedStats] carries + /// the lazily-fetched stats across those toggles so they aren't re-fetched. + void _showRepeaterDetails(Repeater repeater, + {bool isDuplicate = false, + int? regionHopBytesOverride, + bool isolate = true, + bool expand = false, + Future? cachedStats}) { + // Focus this repeater: hide every OTHER repeater while its detail sheet/pill + // is open (web setSoloCircle parity) and track it as the active selection + // (Feature B draw guard + teardown). Claim it as the active view BEFORE + // tearing down any prior view, so the shared focus camera isn't restored + // mid-switch (it stays engaged and re-fits to this repeater below). Restored + // by _clearRepeaterIsolation on sheet/pill close or empty-map tap. + final wasCell = _cellPopupActive; + final prevRepeater = _isolatedRepeaterId; + if (isolate) _isolatedRepeaterId = repeater.id; + _clearMinimizedInfoPopup(); // drop the prior pill widget only + if (wasCell) { + // Switching from a tile view: tear down its footprint/dim/fade/lines. + _clearCellHighlight(); + } else if (prevRepeater != null && prevRepeater != repeater.id) { + // Switching repeater->repeater: tear down the old cells/lines/dim WITHOUT + // clearing the (now new) _isolatedRepeaterId via _clearRepeaterIsolation. + _clearCoverageLines(); + _clearCoverageCells(); + _restoreCoverageBackdropForRepeater(); + } + // Drop any lingering Feature A fade so it doesn't reappear when this + // repeater's isolation is later cleared. + _restoreFadedRepeaters(); + // Switching from a minimized ping focus: tear it down so its pill doesn't + // linger and overlap this repeater view's pill at the shared bottom slot + // (symmetric to _enterPingFocus). When isolating, the repeater view is + // already claimed (_isolatedRepeaterId set above), so this dismiss's + // _exitFocusCameraIfDone no-ops and the shared focus camera stays engaged. + _dismissPingFocus(); + if (isolate) { + _enterFocusCamera(); // save the pre-focus snapshot once; north-up, stop follow + _syncRepeaterSymbols(context.read()); + } + // Determine icon badge color based on primary status final iconColor = _getRepeaterMarkerColor(repeater, isDuplicate); - // Determine status label and color + // Determine status label and color (web labels, generateRepeaterPopup). String statusLabel; Color statusColor; - if (repeater.isNew) { - statusLabel = 'New'; + if (repeater.enabled == 2) { + statusLabel = 'Ambiguous'; + statusColor = _repeaterDuplicateColor; + } else if (repeater.enabled == 0) { + statusLabel = 'Disabled'; + statusColor = _repeaterDeadColor; + } else if (repeater.isNew) { + statusLabel = 'New Repeater'; statusColor = _repeaterNewColor; } else if (repeater.isActive) { - statusLabel = 'Active'; + statusLabel = 'Repeater Online'; statusColor = _repeaterMarkerColor; } else { - statusLabel = 'Stale'; + statusLabel = 'Stale Repeater'; statusColor = _repeaterDeadColor; } - showModalBottomSheet( + // Lazily fetch this repeater's coverage points for the BIDIR/TX/RX/DISC/DEAD + // totals + max range, then aggregate client-side (renderRepeaterChart parity). + final appState = context.read(); + final lookup = RepeaterLookup.fromRepeaters(appState.repeaters, + hopBytes: appState.effectiveHopBytes); + final isImperial = appState.preferences.isImperial; + final cvd = appState.preferences.colorVisionType; + final gridSize = appState.preferences.coverageGridSize; + final Future statsFuture = cachedStats ?? + appState + .fetchRepeaterCoveragePoints(prefix: repeater.id) + .then((pts) { + // null means the points could not be fetched at all. Keep it null so + // the sheet can say so, rather than aggregating an empty list into a + // zeroed RepeaterStats that reads as "this repeater heard nothing" + // (MeshMapper_Server#109). An EMPTY list still aggregates normally: + // that is a real answer. + if (pts == null) { + debugWarn('[COVERAGE] repeater ${repeater.id} coverage ' + 'unavailable, not rendering it as zero coverage'); + return null; + } + final res = + RepeaterStats.fromCoverageWithPoints(pts, repeater, lookup); + // Feature B: draw this repeater's coverage cells + status-coloured + // connection lines (web `drawRepeaterCoverageFromCache` parity). Only + // on the first fetch (cachedStats == null reaches here), and only if + // this repeater is still the isolated selection — guards a fast + // repeater switch during the network fetch — and has a known location. + if (mounted && + _isolatedRepeaterId == repeater.id && + repeater.hasLocation) { + _drawRepeaterCoverage(repeater, res.matched, cvd, gridSize) + .then((cells) { + if (!mounted || _isolatedRepeaterId != repeater.id) return; + // Match ping focus: frame the repeater + its whole coverage + // footprint (no-op when it heard nothing — single point). + _fitCameraToPoints([ + LatLng(repeater.lat, repeater.lon), + for (final c in cells) LatLng(c.centerLat, c.centerLon), + ]); + }); + } + return res.stats; + }).catchError((Object e) { + debugWarn('[COVERAGE] repeater stats failed: $e'); + return null; + }); + + final fingerprintShort = + repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); + final fingerprintFull = repeater.hexId.length >= 8 + ? repeater.hexId.substring(0, 8).toUpperCase() + : repeater.hexId.toUpperCase(); + final clockSkew = humanizeClockSkew(repeater.timeOffset); + + // Open minimized by default — a compact stats pill; tap it to expand to the + // full detail sheet. Isolation (others hidden) persists across pill<->sheet. + if (!expand) { + _setMinimizedInfoPopup(_MinimizedInfoPopup( + title: _repeaterPillTitle(repeater, statusColor), + statsBuilder: (ctx) => _repeaterPillStats( + ctx, repeater, statsFuture, clockSkew, isImperial), + onReshow: () { + _clearMinimizedInfoPopup(); + _showRepeaterDetails(repeater, + isDuplicate: isDuplicate, + regionHopBytesOverride: regionHopBytesOverride, + isolate: false, + expand: true, + cachedStats: statsFuture); + }, + onClose: () { + _clearMinimizedInfoPopup(); + _clearRepeaterIsolation(); + }, + )); + return; + } + + showModalBottomSheet( context: context, useSafeArea: true, + // Transparent barrier so the map stays bright (like focus mode). + barrierColor: Colors.transparent, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Container( - padding: EdgeInsets.fromLTRB(20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Header with icon badge (containing ID) and name - Row( - children: [ - // Icon badge with hex ID (mirrors map marker) - Builder(builder: (context) { - final displayId = repeater.displayHexId(overrideHopBytes: regionHopBytesOverride); - final isLongId = displayId.length > 2; - return Container( - constraints: const BoxConstraints(minWidth: 44), - height: 44, - padding: isLongId - ? const EdgeInsets.symmetric(horizontal: 8) - : EdgeInsets.zero, - decoration: BoxDecoration( - color: iconColor, - borderRadius: BorderRadius.circular(22), - border: Border.all(color: Colors.white, width: 2), - ), - alignment: Alignment.center, - child: Text( - displayId, - style: TextStyle( - fontSize: isLongId ? 13 : 16, - fontWeight: FontWeight.bold, - color: Colors.white, - fontFamily: 'monospace', + padding: EdgeInsets.fromLTRB( + 20, 24, 20, 32 + MediaQuery.of(context).viewPadding.bottom), + // Scrollable so the content can't overflow on shorter screens — this + // (non-scroll-controlled) bottom sheet caps height to ~9/16 of the + // screen, and the detail card is occasionally taller than that. + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header with icon badge (containing ID) and name + Row( + children: [ + // Icon badge with hex ID (mirrors map marker) + Builder(builder: (context) { + final displayId = repeater.displayHexId( + overrideHopBytes: regionHopBytesOverride); + final isLongId = displayId.length > 2; + return Container( + constraints: const BoxConstraints(minWidth: 44), + height: 44, + padding: isLongId + ? const EdgeInsets.symmetric(horizontal: 8) + : EdgeInsets.zero, + decoration: BoxDecoration( + color: iconColor, + borderRadius: BorderRadius.circular(22), + border: Border.all(color: Colors.white, width: 2), ), + alignment: Alignment.center, + child: Text( + displayId, + style: TextStyle( + fontSize: isLongId ? 13 : 16, + fontWeight: FontWeight.bold, + color: Colors.white, + fontFamily: 'monospace', + ), + ), + ); + }), + const SizedBox(width: 12), + Expanded( + child: Text( + repeater.name, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - ); - }), - const SizedBox(width: 12), - Expanded( - child: Text( - repeater.name, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, ), - ), - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => Navigator.pop(context), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), - const SizedBox(height: 16), - - // Status chips row - Row( - children: [ - if (isDuplicate) ...[ - _buildRepeaterStatusChip('Duplicate', _repeaterDuplicateColor), const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.keyboard_arrow_down, size: 20), + onPressed: () => Navigator.pop(context, 'minimized'), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Minimize', + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), ], - _buildRepeaterStatusChip(statusLabel, statusColor), - ], - ), - const SizedBox(height: 16), - - // Details card - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), - child: Column( + const SizedBox(height: 16), + + // Status chips row + Row( children: [ - // Location row - Row( - children: [ - Icon(Icons.location_on, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 8), - Expanded( - child: Text( - '${repeater.lat.toStringAsFixed(5)}, ${repeater.lon.toStringAsFixed(5)}', + if (isDuplicate) ...[ + _buildRepeaterStatusChip( + 'Duplicate', _repeaterDuplicateColor), + const SizedBox(width: 8), + ], + _buildRepeaterStatusChip(statusLabel, statusColor), + ], + ), + const SizedBox(height: 16), + + // Details card + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), + ), + child: Column( + children: [ + // Location + _repRow( + context, + Icons.location_on, + Text( + '${repeater.lat.toStringAsFixed(5)}, ${repeater.lon.toStringAsFixed(5)}', + style: const TextStyle( + fontSize: 13, fontFamily: 'monospace'), + ), + ), + // Fingerprint (id / hex) + _repRow( + context, + Icons.fingerprint, + Text.rich(TextSpan(children: [ + TextSpan( + text: fingerprintShort, + style: const TextStyle( + fontSize: 13, + fontFamily: 'monospace', + fontWeight: FontWeight.w600), + ), + TextSpan( + text: ' ($fingerprintFull)', style: TextStyle( - fontSize: 13, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, + fontSize: 12, + color: + Theme.of(context).colorScheme.onSurfaceVariant, ), ), + ])), + ), + // Hop bytes + _repRow( + context, + Icons.swap_horiz, + Text( + 'Hop Bytes: ${repeater.hopBytes} byte${repeater.hopBytes == 1 ? '' : 's'}', + style: const TextStyle(fontSize: 13), ), - ], - ), - const SizedBox(height: 10), - // Last heard row - Row( + ), + // Last heard (schedule) + if (repeater.lastHeard > 0) + _repRow( + context, + Icons.schedule, + Text(formatDateWithAgo(repeater.lastHeard), + style: const TextStyle(fontSize: 13)), + ), + // Clock-skew warning — single compact row, e.g. "Clock is + // 1.2 days ahead" (was two rows / a long sentence). + if (clockSkew != null) + _repRow( + context, + Icons.warning_amber_rounded, + Text('Clock is $clockSkew', + style: const TextStyle(fontSize: 13)), + color: Colors.red.shade400, + ), + // First heard + if (repeater.createdAt != null) + _repRow( + context, + Icons.event, + Text( + 'First Heard: ${formatDateWithAgo(repeater.createdAt)}', + style: const TextStyle(fontSize: 13)), + ), + // Max range (lazy) + FutureBuilder( + future: statsFuture, + builder: (context, snap) { + final loading = + snap.connectionState != ConnectionState.done; + final stats = snap.data; + // Three states: still loading, could not load, and + // loaded (where a null range means nothing was heard). + final String range; + if (loading) { + range = '…'; + } else if (stats == null) { + range = 'Unavailable'; + } else if (stats.maxRangeMeters != null) { + range = formatCoverageDistance(stats.maxRangeMeters!, + isImperial: isImperial); + } else { + range = 'N/A'; + } + return _repRow( + context, + Icons.open_in_full, + Text('Max Range: $range', + style: const TextStyle(fontSize: 13)), + ); + }, + ), + ], + ), + ), + const SizedBox(height: 14), + // BIDIR/TX/RX/DISC/DEAD totals (lazy — filled after the fetch) + FutureBuilder( + future: statsFuture, + builder: (context, snap) { + final loading = snap.connectionState != ConnectionState.done; + final stats = snap.data; + // Done with no stats means the fetch could not be answered. + // Never fall through to the zero row here: that is the bug. + if (!loading && stats == null) { + // With no zone (Offline Mode, or not connected) there is + // nothing to retry against: fetchRepeaterCoveragePoints + // answers null without making a request, so a Retry button + // would just loop on the same answer. Say why instead. + final hasZone = (appState.zoneCode ?? '').isNotEmpty; + // Pop with our own token: the result handler treats every + // other value, a bare pop included, as a real close and + // tears the selection down. + return _coverageUnavailableRow( + context, + onRetry: hasZone + ? () => Navigator.of(context).pop('retry') + : null, + message: hasZone + ? "Couldn't load coverage" + : 'Connect to load coverage', + ); + } + String v(int? n) => loading ? '…' : '${n ?? 0}'; + final nothingRecorded = !loading && stats!.totalMatched == 0; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Icon(Icons.access_time, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 8), - Expanded( - child: Text( - repeater.lastHeardFormatted, + Row( + children: [ + _repeaterStatCell(context, 'BIDIR', v(stats?.bidir), + const Color(0xFF1E7E34)), + _repeaterStatCell(context, 'TX', v(stats?.tx), + const Color(0xFFFD7E14)), + _repeaterStatCell(context, 'RX', v(stats?.rx), + const Color(0xFF6F42C1)), + _repeaterStatCell(context, 'DISC', v(stats?.disc), + const Color(0xFF17A2B8)), + _repeaterStatCell(context, 'DEAD', v(stats?.dead), + const Color(0xFF6C757D)), + ], + ), + if (nothingRecorded) ...[ + const SizedBox(height: 10), + Text( + 'No coverage recorded yet', + textAlign: TextAlign.center, style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurface, + fontSize: 12, + color: + Theme.of(context).colorScheme.onSurfaceVariant, ), ), - ), + ], ], - ), - ], + ); + }, ), + ], + ), + ), + ), + ).then((result) { + if (!mounted) return; + if (result == 'retry') { + // Re-open expanded with NO cachedStats, which re-runs the fetch. The + // selection stays isolated throughout, same as the minimize path. + debugLog('[COVERAGE] retrying coverage fetch for ${repeater.id}'); + _showRepeaterDetails(repeater, + isDuplicate: isDuplicate, + regionHopBytesOverride: regionHopBytesOverride, + isolate: false, + expand: true); + } else if (result == 'minimized') { + // Collapse back to the stats pill. The selection (and its coverage + // cells/lines + tile dim) persists throughout pill<->sheet; it's torn + // down only on a real close. Reuse the already-fetched stats so the pill + // doesn't re-fetch (and doesn't redraw the coverage). + _showRepeaterDetails(repeater, + isDuplicate: isDuplicate, + regionHopBytesOverride: regionHopBytesOverride, + isolate: false, + expand: false, + cachedStats: statsFuture); + } else { + // Dismissed for real (X / swipe / barrier tap): clear the coverage + // cells/lines and restore the dimmed coverage tiles. + _clearRepeaterIsolation(); + } + }); + } + + /// One labelled icon row in the repeater detail card. A null [icon] leaves the + /// icon gutter blank (used to indent the clock-skew magnitude under its warning). + Widget _repRow(BuildContext context, IconData? icon, Widget child, + {Color? color}) { + final iconColor = color ?? Theme.of(context).colorScheme.onSurfaceVariant; + final textColor = color ?? Theme.of(context).colorScheme.onSurface; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 20, + child: icon == null ? null : Icon(icon, size: 16, color: iconColor), + ), + const SizedBox(width: 8), + Expanded( + child: DefaultTextStyle.merge( + style: TextStyle(fontSize: 13, color: textColor), + child: child, ), - ], + ), + ], + ), + ); + } + + /// One cell of the repeater's BIDIR/TX/RX/DISC/DEAD totals row. + /// Shown in place of the BIDIR/TX/RX/DISC/DEAD row when the coverage fetch + /// could not be answered at all. Distinct from a repeater that was looked up + /// successfully and simply has no coverage, which keeps the zero row plus a + /// "no coverage recorded yet" caption (MeshMapper_Server#109). + /// + /// [onRetry] re-enters the sheet with no cached future. The stats future is + /// cached per selection and handed to both the pill and the sheet, so + /// toggling between them would otherwise just re-render the same failure. + /// A null [onRetry] means there is nothing to retry against, so the row + /// states the reason rather than offering a button that cannot work. + Widget _coverageUnavailableRow( + BuildContext context, { + required VoidCallback? onRetry, + required String message, + }) { + final scheme = Theme.of(context).colorScheme; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.cloud_off, size: 16, color: scheme.onSurfaceVariant), + const SizedBox(width: 8), + Flexible( + child: Text( + message, + style: TextStyle(fontSize: 13, color: scheme.onSurfaceVariant), + ), ), + if (onRetry != null) ...[ + const SizedBox(width: 8), + TextButton( + onPressed: onRetry, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12), + minimumSize: const Size(0, 32), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('Retry'), + ), + ], + ], + ); + } + + Widget _repeaterStatCell( + BuildContext context, String label, String value, Color color) { + return Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(value, + style: TextStyle( + fontSize: 18, fontWeight: FontWeight.bold, color: color)), + const SizedBox(height: 2), + Text(label, + style: TextStyle( + fontSize: 10, + color: Theme.of(context).colorScheme.onSurfaceVariant)), + ], ), ); } @@ -2615,6 +10045,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { /// Paints a crisp directional arrow pointing up class _ArrowPainter extends CustomPainter { + const _ArrowPainter(); + @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); @@ -2652,6 +10084,640 @@ class _ArrowPainter extends CustomPainter { bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } +/// Paints a car silhouette for GPS position marker +class _CarMarkerPainter extends CustomPainter { + const _CarMarkerPainter(); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + // White outline + final outlinePaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.fill; + + // Car body outline (rounded rect, slightly larger) + final outlineRect = RRect.fromRectAndRadius( + Rect.fromCenter(center: Offset(cx, cy), width: 14, height: 20), + const Radius.circular(4), + ); + canvas.drawRRect(outlineRect, outlinePaint); + + // Blue car body + final bodyPaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.fill; + + final bodyRect = RRect.fromRectAndRadius( + Rect.fromCenter(center: Offset(cx, cy), width: 11, height: 17), + const Radius.circular(3), + ); + canvas.drawRRect(bodyRect, bodyPaint); + + // Windshield (darker blue rectangle near top) + final windshieldPaint = Paint() + ..color = const Color(0xFF1565C0) + ..style = PaintingStyle.fill; + final windshieldRect = RRect.fromRectAndRadius( + Rect.fromCenter(center: Offset(cx, cy - 3), width: 7, height: 4), + const Radius.circular(1), + ); + canvas.drawRRect(windshieldRect, windshieldPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Paints a bicycle silhouette for GPS position marker +class _BikeMarkerPainter extends CustomPainter { + const _BikeMarkerPainter(); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + final outlinePaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 3; + + final bikePaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5 + ..strokeCap = StrokeCap.round; + + final fillPaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.fill; + + // Two wheels + const wheelR = 4.0; + final leftWheel = Offset(cx - 5, cy + 3); + final rightWheel = Offset(cx + 5, cy + 3); + + // White outlines for wheels + canvas.drawCircle(leftWheel, wheelR + 1, outlinePaint); + canvas.drawCircle(rightWheel, wheelR + 1, outlinePaint); + + // Frame outline + final framePath = ui.Path() + ..moveTo(leftWheel.dx, leftWheel.dy) + ..lineTo(cx, cy - 5) // Up to handlebars + ..lineTo(rightWheel.dx, rightWheel.dy) // Down to rear + ..moveTo(cx, cy - 5) + ..lineTo(cx + 2, cy - 7); // Handlebar + canvas.drawPath( + framePath, + Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 3.5 + ..strokeCap = StrokeCap.round); + + // Blue wheels + canvas.drawCircle(leftWheel, wheelR, bikePaint); + canvas.drawCircle(rightWheel, wheelR, bikePaint); + + // Blue frame + canvas.drawPath(framePath, bikePaint); + + // Seat dot + canvas.drawCircle(Offset(cx - 1, cy - 4), 1.5, fillPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Paints a boat silhouette for GPS position marker +class _BoatMarkerPainter extends CustomPainter { + const _BoatMarkerPainter(); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + // White outline + final outlinePaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.fill; + + final fillPaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.fill; + + // Hull outline (wider) + final hullOutline = ui.Path() + ..moveTo(cx - 9, cy + 1) + ..lineTo(cx - 6, cy + 8) + ..lineTo(cx + 6, cy + 8) + ..lineTo(cx + 9, cy + 1) + ..close(); + canvas.drawPath(hullOutline, outlinePaint); + + // Hull fill + final hull = ui.Path() + ..moveTo(cx - 7, cy + 2) + ..lineTo(cx - 5, cy + 7) + ..lineTo(cx + 5, cy + 7) + ..lineTo(cx + 7, cy + 2) + ..close(); + canvas.drawPath(hull, fillPaint); + + // Mast outline + canvas.drawLine( + Offset(cx, cy + 2), + Offset(cx, cy - 9), + Paint() + ..color = Colors.white + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round); + // Mast + canvas.drawLine( + Offset(cx, cy + 2), + Offset(cx, cy - 9), + Paint() + ..color = const Color(0xFF2196F3) + ..strokeWidth = 1.5 + ..strokeCap = StrokeCap.round); + + // Sail outline + final sailOutline = ui.Path() + ..moveTo(cx + 1, cy - 8) + ..lineTo(cx + 7, cy) + ..lineTo(cx + 1, cy) + ..close(); + canvas.drawPath(sailOutline, outlinePaint); + + // Sail + final sail = ui.Path() + ..moveTo(cx + 1, cy - 7) + ..lineTo(cx + 6, cy - 0.5) + ..lineTo(cx + 1, cy - 0.5) + ..close(); + canvas.drawPath( + sail, + Paint() + ..color = const Color(0xFF64B5F6) + ..style = PaintingStyle.fill); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Paints a walking person silhouette for GPS position marker +class _WalkMarkerPainter extends CustomPainter { + const _WalkMarkerPainter(); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + final outlinePaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 3.5 + ..strokeCap = StrokeCap.round; + + final personPaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.8 + ..strokeCap = StrokeCap.round; + + final fillPaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.fill; + + // Head outline + fill + canvas.drawCircle( + Offset(cx, cy - 7), + 3.5, + Paint() + ..color = Colors.white + ..style = PaintingStyle.fill); + canvas.drawCircle(Offset(cx, cy - 7), 2.5, fillPaint); + + // Body outline + canvas.drawLine(Offset(cx, cy - 4), Offset(cx, cy + 3), outlinePaint); + // Body + canvas.drawLine(Offset(cx, cy - 4), Offset(cx, cy + 3), personPaint); + + // Arms outline + canvas.drawLine( + Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), outlinePaint); + // Arms + canvas.drawLine( + Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), personPaint); + + // Left leg outline + canvas.drawLine(Offset(cx, cy + 3), Offset(cx - 4, cy + 10), outlinePaint); + // Right leg outline + canvas.drawLine(Offset(cx, cy + 3), Offset(cx + 4, cy + 10), outlinePaint); + // Left leg + canvas.drawLine(Offset(cx, cy + 3), Offset(cx - 4, cy + 10), personPaint); + // Right leg + canvas.drawLine(Offset(cx, cy + 3), Offset(cx + 4, cy + 10), personPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Paints a Chomper (cyan wedge) GPS position marker — mouth faces up (direction of travel) +class _ChomperMarkerPainter extends CustomPainter { + const _ChomperMarkerPainter(); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + const radius = 10.0; + + // Mouth opening angle: 70° total (35° each side of the top) + // In canvas coordinates, 0° = 3 o'clock, so "up" = -90° = -π/2. + // Arc sweeps clockwise. We start at (-90 + 35)° and sweep (360 - 70)°. + const mouthAngle = 70.0 * (math.pi / 180); + const startAngle = -math.pi / 2 + mouthAngle / 2; // right edge of mouth + const sweepAngle = 2 * math.pi - mouthAngle; + + // White outline for visibility + final outlinePaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.fill; + + final outlinePath = ui.Path() + ..moveTo(cx, cy) + ..arcTo( + Rect.fromCircle(center: Offset(cx, cy), radius: radius + 1.5), + startAngle, + sweepAngle, + false, + ) + ..close(); + canvas.drawPath(outlinePath, outlinePaint); + + // Cyan body + final bodyPaint = Paint() + ..color = const Color(0xFF00BCD4) + ..style = PaintingStyle.fill; + + final bodyPath = ui.Path() + ..moveTo(cx, cy) + ..arcTo( + Rect.fromCircle(center: Offset(cx, cy), radius: radius), + startAngle, + sweepAngle, + false, + ) + ..close(); + canvas.drawPath(bodyPath, bodyPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Paints a dog silhouette (side profile, facing right) for GPS position marker +class _DogMarkerPainter extends CustomPainter { + const _DogMarkerPainter(); + + static const _blue = Color(0xFF2196F3); + static const _darkBlue = Color(0xFF1565C0); + + /// Body, neck, head and muzzle as one closed silhouette around [cx],[cy]. + ui.Path _bodyPath(double cx, double cy) { + return ui.Path() + ..moveTo(cx - 12, cy - 3) // rump + ..quadraticBezierTo(cx - 7, cy - 5.5, cx - 2, cy - 5) // back + ..quadraticBezierTo(cx + 1, cy - 5, cx + 2.5, cy - 7) // withers + ..lineTo(cx + 3.5, cy - 9.5) // nape + ..quadraticBezierTo(cx + 4, cy - 11.5, cx + 6.5, cy - 11) // skull + ..quadraticBezierTo(cx + 8.5, cy - 10.5, cx + 8.5, cy - 8.5) // stop + ..lineTo(cx + 11.5, cy - 7.5) // muzzle + ..quadraticBezierTo(cx + 12.5, cy - 7, cx + 12, cy - 5.5) // nose + ..lineTo(cx + 8, cy - 5) // under the muzzle + ..quadraticBezierTo(cx + 6.5, cy - 4.5, cx + 6, cy - 2.5) // throat + ..quadraticBezierTo(cx + 5.5, cy, cx + 4, cy + 1.5) // chest + ..quadraticBezierTo(cx - 2, cy + 3.5, cx - 8, cy + 1.5) // belly + ..quadraticBezierTo(cx - 12.5, cy + 0.5, cx - 12, cy - 3) // haunch + ..close(); + } + + /// Tail, sweeping up and back off the rump. + ui.Path _tailPath(double cx, double cy) { + return ui.Path() + ..moveTo(cx - 11.5, cy - 3) + ..quadraticBezierTo(cx - 14, cy - 6, cx - 12.5, cy - 10); + } + + /// Floppy ear, hanging down over the cheek. + ui.Path _earPath(double cx, double cy) { + return ui.Path() + ..moveTo(cx + 3.2, cy - 9.5) + ..quadraticBezierTo(cx + 2.2, cy - 6.5, cx + 3.5, cy - 4.5) + ..quadraticBezierTo(cx + 5.5, cy - 4, cx + 6, cy - 6) + ..quadraticBezierTo(cx + 6, cy - 8.5, cx + 5, cy - 10) + ..close(); + } + + /// Near pair drawn a little ahead of the far pair, for depth. + static const _legs = <(double, double, double, double)>[ + (4, 0, 4.5, 8), // front near + (1.5, 0, 1, 7.5), // front far + (-8, 0, -9, 8), // hind near + (-5.5, 0, -6, 7.5), // hind far + ]; + + void _drawLegs(Canvas canvas, double cx, double cy, Paint paint) { + for (final (x1, y1, x2, y2) in _legs) { + canvas.drawLine( + Offset(cx + x1, cy + y1), Offset(cx + x2, cy + y2), paint); + } + } + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + final body = _bodyPath(cx, cy); + final tail = _tailPath(cx, cy); + + // Every white outline goes down before any blue, so the halo around one + // limb never paints over the blue of the one beside it. + final legOutline = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 5 + ..strokeCap = StrokeCap.round; + final bodyOutline = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 3.5 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + + _drawLegs(canvas, cx, cy, legOutline); + canvas.drawPath(tail, legOutline); + canvas.drawPath(body, bodyOutline); + + final legPaint = Paint() + ..color = _blue + ..style = PaintingStyle.stroke + ..strokeWidth = 2.6 + ..strokeCap = StrokeCap.round; + + _drawLegs(canvas, cx, cy, legPaint); + canvas.drawPath(tail, legPaint); + canvas.drawPath(body, Paint()..color = _blue); + + // Ear and nose read as darker shading on top of the body, the way the car + // marker's windshield does — no white outline, so nothing separates limbs. + canvas.drawPath(_earPath(cx, cy), Paint()..color = _darkBlue); + canvas.drawCircle( + Offset(cx + 11.3, cy - 6.3), 0.9, Paint()..color = _darkBlue); + canvas.drawCircle( + Offset(cx + 7, cy - 8.5), 1, Paint()..color = Colors.white); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Paints a teardrop/pin marker for coverage dots +class _PinMarkerPainter extends CustomPainter { + final Color color; + const _PinMarkerPainter(this.color); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + final fillPaint = Paint() + ..color = color + ..style = PaintingStyle.fill; + final outlinePaint = Paint() + ..color = Colors.white.withValues(alpha: 0.8) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + final shadowPaint = Paint() + ..color = Colors.black.withValues(alpha: 0.15) + ..style = PaintingStyle.fill + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 1.5); + + const headRadius = 6.0; + final headCenter = Offset(cx, cy - 2); + final tipY = cy + 9; + + // Combined shadow + final pinPath = ui.Path() + ..addOval(Rect.fromCircle(center: headCenter, radius: headRadius)) + ..moveTo(cx - 4, cy + 1) + ..lineTo(cx, tipY) + ..lineTo(cx + 4, cy + 1) + ..close(); + canvas.drawPath(pinPath, shadowPaint); + + // Triangle point + final triPath = ui.Path() + ..moveTo(cx - 4, cy + 1) + ..lineTo(cx, tipY) + ..lineTo(cx + 4, cy + 1) + ..close(); + canvas.drawPath(triPath, fillPaint); + + // Circle head + canvas.drawCircle(headCenter, headRadius, fillPaint); + canvas.drawCircle(headCenter, headRadius, outlinePaint); + + // Inner dot + canvas.drawCircle( + headCenter, 2.0, Paint()..color = Colors.white.withValues(alpha: 0.9)); + } + + @override + bool shouldRepaint(covariant _PinMarkerPainter oldDelegate) => + oldDelegate.color != color; +} + +/// Paints a diamond marker for coverage dots +class _DiamondMarkerPainter extends CustomPainter { + final Color color; + const _DiamondMarkerPainter(this.color); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + final outlinePaint = Paint() + ..color = Colors.white.withValues(alpha: 0.7) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + + final fillPaint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + final shadowPaint = Paint() + ..color = Colors.black.withValues(alpha: 0.12) + ..style = PaintingStyle.fill + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 1.5); + + final path = ui.Path() + ..moveTo(cx, cy - 8) // Top + ..lineTo(cx + 8, cy) // Right + ..lineTo(cx, cy + 8) // Bottom + ..lineTo(cx - 8, cy) // Left + ..close(); + + canvas.drawPath(path, shadowPaint); + canvas.drawPath(path, fillPaint); + canvas.drawPath(path, outlinePaint); + } + + @override + bool shouldRepaint(covariant _DiamondMarkerPainter oldDelegate) => + oldDelegate.color != color; +} + +/// Paints a repeater marker shape (filled colored rounded box with white border +/// and drop shadow). Used at startup to generate bitmap variants for native +/// MapLibre symbols. The text (hex ID) is rendered separately by the symbol's +/// `textField` property at runtime — this painter only draws the box itself. +class _RepeaterShapePainter extends CustomPainter { + final Color fillColor; + final double borderRadius; + + const _RepeaterShapePainter({ + required this.fillColor, + required this.borderRadius, + }); + + @override + void paint(Canvas canvas, Size size) { + // Inset the box by the shadow blur amount so the shadow has room to draw + const shadowBlur = 4.0; + final boxRect = Rect.fromLTWH( + shadowBlur, + shadowBlur, + size.width - 2 * shadowBlur, + size.height - 2 * shadowBlur, + ); + + // Drop shadow (positioned 2px below the box) + final shadowPaint = Paint() + ..color = Colors.black26 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, shadowBlur); + canvas.drawRRect( + RRect.fromRectAndRadius( + boxRect.shift(const Offset(0, 2)), + Radius.circular(borderRadius), + ), + shadowPaint, + ); + + // Filled colored box + final fillPaint = Paint()..color = fillColor; + canvas.drawRRect( + RRect.fromRectAndRadius(boxRect, Radius.circular(borderRadius)), + fillPaint, + ); + + // White border (2px wide, drawn inside the box edge) + final borderPaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0; + final innerRect = boxRect.deflate(1); + canvas.drawRRect( + RRect.fromRectAndRadius(innerRect, Radius.circular(borderRadius - 1)), + borderPaint, + ); + } + + @override + bool shouldRepaint(covariant _RepeaterShapePainter old) => + old.fillColor != fillColor || old.borderRadius != borderRadius; +} + +/// Paints a coverage ping marker (TX/RX/DISC/Trace) in one of the four user +/// styles. Used at startup to generate bitmap variants for native MapLibre +/// symbols. Reuses _PinMarkerPainter and _DiamondMarkerPainter for those styles. +class _CoverageMarkerPainter extends CustomPainter { + final String style; // 'circle' / 'pin' / 'diamond' / 'dot' + final Color color; + + const _CoverageMarkerPainter({required this.style, required this.color}); + + @override + void paint(Canvas canvas, Size size) { + // Visible glyph area — the canvas is typically larger (40×40) so the + // surrounding pixels stay transparent, giving MapLibre a bigger native + // tap hit target without enlarging the actual marker visual. + const innerSize = Size(24, 24); + final dx = (size.width - innerSize.width) / 2; + final dy = (size.height - innerSize.height) / 2; + canvas.save(); + canvas.translate(dx, dy); + switch (style) { + case 'pin': + _PinMarkerPainter(color).paint(canvas, innerSize); + break; + case 'diamond': + _DiamondMarkerPainter(color).paint(canvas, innerSize); + break; + case 'circle': + _paintCircle(canvas, innerSize, borderAlpha: 1.0, borderWidth: 2.0); + break; + case 'dot': + default: + _paintCircle(canvas, innerSize, borderAlpha: 0.6, borderWidth: 1.5); + break; + } + canvas.restore(); + } + + void _paintCircle(Canvas canvas, Size size, + {required double borderAlpha, required double borderWidth}) { + final center = Offset(size.width / 2, size.height / 2); + final radius = math.min(size.width, size.height) / 2 - 2; + + // Drop shadow (slightly below) + final shadowPaint = Paint() + ..color = Colors.black12 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2); + canvas.drawCircle(center.translate(0, 1), radius, shadowPaint); + + // Filled circle + canvas.drawCircle(center, radius, Paint()..color = color); + + // White border + canvas.drawCircle( + center, + radius, + Paint() + ..color = Colors.white.withValues(alpha: borderAlpha) + ..style = PaintingStyle.stroke + ..strokeWidth = borderWidth, + ); + } + + @override + bool shouldRepaint(covariant _CoverageMarkerPainter old) => + old.style != style || old.color != color; +} + /// A stateful widget for sound item with play button visual feedback class _SoundItemWidget extends StatefulWidget { final IconData icon; @@ -2702,7 +10768,9 @@ class _SoundItemWidgetState extends State<_SoundItemWidget> { : Colors.blue.withValues(alpha: 0.2), shape: BoxShape.circle, border: Border.all( - color: _isPlaying ? Colors.blue : Colors.blue.withValues(alpha: 0.5), + color: _isPlaying + ? Colors.blue + : Colors.blue.withValues(alpha: 0.5), width: _isPlaying ? 2 : 1, ), ), diff --git a/lib/widgets/noise_floor_chart.dart b/lib/widgets/noise_floor_chart.dart index 31ba9332..0cc073e0 100644 --- a/lib/widgets/noise_floor_chart.dart +++ b/lib/widgets/noise_floor_chart.dart @@ -4,20 +4,25 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/noise_floor_session.dart'; import '../providers/app_state_provider.dart'; +import '../utils/ping_colors.dart'; import 'repeater_id_chip.dart'; +import 'rx_path_chain.dart'; /// Interactive noise floor chart with pinch-to-zoom and pan class InteractiveNoiseFloorChart extends StatefulWidget { final NoiseFloorSession session; final bool isLive; - const InteractiveNoiseFloorChart({super.key, required this.session, this.isLive = false}); + const InteractiveNoiseFloorChart( + {super.key, required this.session, this.isLive = false}); @override - State createState() => InteractiveNoiseFloorChartState(); + State createState() => + InteractiveNoiseFloorChartState(); } -class InteractiveNoiseFloorChartState extends State { +class InteractiveNoiseFloorChartState + extends State { // View window in seconds late double _viewStart; late double _viewEnd; @@ -67,7 +72,8 @@ class InteractiveNoiseFloorChartState extends State final effectiveTotal = newTotal < 60 ? 60.0 : newTotal; // Detect if user is at full (unzoomed) view: start near 0 and end near total - final isFullView = _viewStart < 2.0 && (_totalDuration - _viewEnd).abs() < 2.0; + final isFullView = + _viewStart < 2.0 && (_totalDuration - _viewEnd).abs() < 2.0; _totalDuration = effectiveTotal; @@ -91,14 +97,18 @@ class InteractiveNoiseFloorChartState extends State double get _visibleDuration => _viewEnd - _viewStart; double get _zoomLevel => _totalDuration / _visibleDuration; - void _handleScaleStart(ScaleStartDetails details, double chartWidth, double chartLeft) { + void _handleScaleStart( + ScaleStartDetails details, double chartWidth, double chartLeft) { _gestureStartViewStart = _viewStart; _gestureStartViewEnd = _viewEnd; _gestureStartFocalX = details.localFocalPoint.dx; } - void _handleScaleUpdate(ScaleUpdateDetails details, double chartWidth, double chartLeft) { - if (_gestureStartViewStart == null || _gestureStartViewEnd == null || _gestureStartFocalX == null) { + void _handleScaleUpdate( + ScaleUpdateDetails details, double chartWidth, double chartLeft) { + if (_gestureStartViewStart == null || + _gestureStartViewEnd == null || + _gestureStartFocalX == null) { return; } @@ -109,7 +119,8 @@ class InteractiveNoiseFloorChartState extends State newDuration = newDuration.clamp(_minVisibleSeconds, _totalDuration); // Calculate focal point ratio in chart space - final focalRatio = ((_gestureStartFocalX! - chartLeft) / chartWidth).clamp(0.0, 1.0); + final focalRatio = + ((_gestureStartFocalX! - chartLeft) / chartWidth).clamp(0.0, 1.0); // Time at focal point in original view final focalTime = _gestureStartViewStart! + (startDuration * focalRatio); @@ -149,7 +160,8 @@ class InteractiveNoiseFloorChartState extends State } /// Check if tap hit a marker and show popup if so - void _handleTap(TapUpDetails details, double chartWidth, double chartLeft, double chartHeight, double chartTop) { + void _handleTap(TapUpDetails details, double chartWidth, double chartLeft, + double chartHeight, double chartTop) { final session = widget.session; if (session.markers.isEmpty || session.samples.isEmpty) return; @@ -160,7 +172,8 @@ class InteractiveNoiseFloorChartState extends State // Find if tap is within any marker for (final marker in session.markers) { - final elapsed = marker.timestamp.difference(session.startTime).inSeconds.toDouble(); + final elapsed = + marker.timestamp.difference(session.startTime).inSeconds.toDouble(); if (elapsed < _viewStart || elapsed > _viewEnd) continue; @@ -175,7 +188,8 @@ class InteractiveNoiseFloorChartState extends State final tapX = details.localPosition.dx; final tapY = details.localPosition.dy; - final distance = ((tapX - markerX) * (tapX - markerX) + (tapY - markerY) * (tapY - markerY)); + final distance = ((tapX - markerX) * (tapX - markerX) + + (tapY - markerY) * (tapY - markerY)); if (distance <= _markerTapRadius * _markerTapRadius) { _showMarkerDetails(marker, noiseFloorOnLine.round()); return; @@ -184,9 +198,14 @@ class InteractiveNoiseFloorChartState extends State } /// Interpolate noise floor at given elapsed time - double _interpolateNoiseFloor(double elapsedSeconds, NoiseFloorSession session) { - if (session.samples.isEmpty) return widget.session.noiseFloorRange.min.toDouble(); - if (session.samples.length == 1) return session.samples.first.noiseFloor.toDouble(); + double _interpolateNoiseFloor( + double elapsedSeconds, NoiseFloorSession session) { + if (session.samples.isEmpty) { + return widget.session.noiseFloorRange.min.toDouble(); + } + if (session.samples.length == 1) { + return session.samples.first.noiseFloor.toDouble(); + } NoiseFloorSample? before; NoiseFloorSample? after; @@ -194,7 +213,8 @@ class InteractiveNoiseFloorChartState extends State double afterElapsed = 0; for (final sample in session.samples) { - final sampleElapsed = sample.timestamp.difference(session.startTime).inSeconds.toDouble(); + final sampleElapsed = + sample.timestamp.difference(session.startTime).inSeconds.toDouble(); if (sampleElapsed <= elapsedSeconds) { before = sample; @@ -209,8 +229,10 @@ class InteractiveNoiseFloorChartState extends State if (before == null) return session.samples.first.noiseFloor.toDouble(); if (after == null) return before.noiseFloor.toDouble(); - final timeFraction = (elapsedSeconds - beforeElapsed) / (afterElapsed - beforeElapsed); - return before.noiseFloor + (after.noiseFloor - before.noiseFloor) * timeFraction; + final timeFraction = + (elapsedSeconds - beforeElapsed) / (afterElapsed - beforeElapsed); + return before.noiseFloor + + (after.noiseFloor - before.noiseFloor) * timeFraction; } /// Show marker details popup as a modern bottom sheet @@ -218,17 +240,24 @@ class InteractiveNoiseFloorChartState extends State final eventTypeLabel = switch (marker.type) { PingEventType.txSuccess => 'TX Success', PingEventType.txFail => 'TX Failed', + PingEventType.txMultiHopOnly => 'TX Multi-hop', PingEventType.rx => 'RX Received', PingEventType.discSuccess => 'Discovery Success', PingEventType.discFail => 'Discovery Failed', + PingEventType.traceSuccess => 'Trace Success', + PingEventType.traceFail => 'Trace Failed', }; final eventDescription = switch (marker.type) { PingEventType.txSuccess => 'Ping was heard by repeater(s)', PingEventType.txFail => 'Ping was not heard by any repeater', + PingEventType.txMultiHopOnly => + 'Ping was heard via multi-hop only (no direct)', PingEventType.rx => 'Received passive observation', PingEventType.discSuccess => 'Discovery got response', PingEventType.discFail => 'Discovery got no response', + PingEventType.traceSuccess => 'Trace got response from target', + PingEventType.traceFail => 'Trace got no response from target', }; final hasLocation = marker.latitude != null && marker.longitude != null; @@ -255,7 +284,10 @@ class InteractiveNoiseFloorChartState extends State width: 40, height: 4, decoration: BoxDecoration( - color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.3), borderRadius: BorderRadius.circular(2), ), ), @@ -277,8 +309,8 @@ class InteractiveNoiseFloorChartState extends State Text( eventTypeLabel, style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + fontWeight: FontWeight.w600, + ), ), const Spacer(), Text( @@ -320,7 +352,8 @@ class InteractiveNoiseFloorChartState extends State context, icon: Icons.location_on, label: 'Location', - value: '${marker.latitude!.toStringAsFixed(4)}, ${marker.longitude!.toStringAsFixed(4)}', + value: + '${marker.latitude!.toStringAsFixed(4)}, ${marker.longitude!.toStringAsFixed(4)}', compact: true, ), ), @@ -328,64 +361,10 @@ class InteractiveNoiseFloorChartState extends State ], ), - // Repeaters section (table format like TX log) - if (marker.repeaters != null && marker.repeaters!.isNotEmpty) ...[ - const SizedBox(height: 16), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), - ), - child: Column( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: Row( - children: [ - SizedBox( - width: 50, - child: Text( - 'Node', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'SNR', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - Expanded( - child: Text( - 'RSSI', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ), - Divider(height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...marker.repeaters!.map((r) => _buildRepeaterRow(context, r)), - ], - ), - ), + // Repeaters section — split into direct and multi-hop + if (marker.repeaters != null && + marker.repeaters!.isNotEmpty) ...[ + ..._buildMarkerRepeaterSections(context, marker.repeaters!), ], // View on Map button @@ -396,7 +375,8 @@ class InteractiveNoiseFloorChartState extends State child: FilledButton.icon( onPressed: () { // Get references before popping - final appState = Provider.of(context, listen: false); + final appState = Provider.of(context, + listen: false); final navigator = Navigator.of(context); // Pop the bottom sheet first @@ -407,7 +387,8 @@ class InteractiveNoiseFloorChartState extends State navigator.popUntil((route) => route.isFirst); // Navigate to map and center on location - appState.navigateToMapCoordinates(marker.latitude!, marker.longitude!); + appState.navigateToMapCoordinates( + marker.latitude!, marker.longitude!); }, icon: const Icon(Icons.map, size: 18), label: const Text('View on Map'), @@ -439,7 +420,10 @@ class InteractiveNoiseFloorChartState extends State return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), ), child: Column( @@ -476,40 +460,172 @@ class InteractiveNoiseFloorChartState extends State ); } - /// Build a table row for a repeater (matching TX log style) - Widget _buildRepeaterRow(BuildContext context, MarkerRepeaterInfo repeater) { - // SNR color: good (>5), fair (-1 to 5), poor (<-1) - Color snrColor; - if (repeater.snr <= -1) { - snrColor = Colors.red; - } else if (repeater.snr <= 5) { - snrColor = Colors.orange; - } else { - snrColor = Colors.green; + /// Build direct and multi-hop repeater sections for the marker detail sheet + List _buildMarkerRepeaterSections( + BuildContext context, List repeaters) { + final directRepeaters = repeaters.where((r) => r.pathHops == null).toList(); + final multiHopRepeaters = + repeaters.where((r) => r.pathHops != null).toList(); + + final widgets = []; + + Widget buildTableHeader() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + SizedBox( + width: 50, + child: Text('Node', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant)), + ), + Expanded( + child: Text('SNR', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant)), + ), + Expanded( + child: Text('RSSI', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant)), + ), + ], + ), + ); + } + + // Direct echoes table + if (directRepeaters.isNotEmpty) { + widgets.addAll([ + const SizedBox(height: 16), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), + ), + child: Column( + children: [ + buildTableHeader(), + Divider(height: 1, color: Theme.of(context).dividerColor), + ...directRepeaters.map((r) => _buildRepeaterRow(context, r)), + ], + ), + ), + ]); } - // RSSI color based on signal strength - Color rssiColor; - if (repeater.rssi >= -70) { - rssiColor = Colors.green; - } else if (repeater.rssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; + // Multi-hop echoes section + if (multiHopRepeaters.isNotEmpty) { + widgets.addAll([ + const SizedBox(height: 12), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + Icon(Icons.route, size: 12, color: PingColors.rx), + const SizedBox(width: 6), + Text('Multi-hop Repeats', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant)), + ], + ), + ), + Divider(height: 1, color: Theme.of(context).dividerColor), + buildTableHeader(), + Divider(height: 1, color: Theme.of(context).dividerColor), + ...multiHopRepeaters.map((r) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildRepeaterRow(context, r), + if (r.pathHops != null && r.pathHops!.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + left: 10, right: 10, bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon(Icons.route, + size: 10, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant), + ), + const SizedBox(width: 4), + Expanded( + child: RxPathChain( + hops: r.pathHops!, + fontSize: 9, + ), + ), + ], + ), + ), + ], + )), + ], + ), + ), + ]); } + return widgets; + } + + /// Build a table row for a repeater (matching TX log style) + Widget _buildRepeaterRow(BuildContext context, MarkerRepeaterInfo repeater) { + final snrColor = PingColors.snrColor(repeater.snr); + final rssiColor = PingColors.rssiColor(repeater.rssi); + return InkWell( - onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId, fullHexId: repeater.pubkeyHex), + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, repeater.repeaterId, + fullHexId: repeater.pubkeyHex), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ // Node ID - RepeaterIdChip(repeaterId: repeater.repeaterId, fontSize: 11, width: 50), + RepeaterIdChip( + repeaterId: repeater.repeaterId, fontSize: 11, width: 50), // SNR chip Expanded( child: Center( - child: _buildValueChip(repeater.snr.toStringAsFixed(1), snrColor), + child: + _buildValueChip(repeater.snr.toStringAsFixed(1), snrColor), ), ), // RSSI chip @@ -587,24 +703,32 @@ class InteractiveNoiseFloorChartState extends State Expanded( child: LayoutBuilder( builder: (context, constraints) { - final chartWidth = constraints.maxWidth - leftPadding - rightPadding; + final chartWidth = + constraints.maxWidth - leftPadding - rightPadding; - final chartHeight = constraints.maxHeight - topPadding - 36.0; // 36 = bottom axis reserved + final chartHeight = constraints.maxHeight - + topPadding - + 36.0; // 36 = bottom axis reserved return RawGestureDetector( gestures: { - ScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers( + ScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers< + ScaleGestureRecognizer>( () => ScaleGestureRecognizer(), (ScaleGestureRecognizer instance) { - instance.onStart = (details) => _handleScaleStart(details, chartWidth, leftPadding); - instance.onUpdate = (details) => _handleScaleUpdate(details, chartWidth, leftPadding); + instance.onStart = (details) => + _handleScaleStart(details, chartWidth, leftPadding); + instance.onUpdate = (details) => + _handleScaleUpdate(details, chartWidth, leftPadding); instance.onEnd = _handleScaleEnd; }, ), - TapGestureRecognizer: GestureRecognizerFactoryWithHandlers( + TapGestureRecognizer: GestureRecognizerFactoryWithHandlers< + TapGestureRecognizer>( () => TapGestureRecognizer(), (TapGestureRecognizer instance) { - instance.onTapUp = (details) => _handleTap(details, chartWidth, leftPadding, chartHeight, topPadding); + instance.onTapUp = (details) => _handleTap(details, + chartWidth, leftPadding, chartHeight, topPadding); }, ), }, @@ -613,7 +737,8 @@ class InteractiveNoiseFloorChartState extends State children: [ // Line chart - wrapped in IgnorePointer so it doesn't steal gestures Padding( - padding: const EdgeInsets.only(top: topPadding, right: rightPadding), + padding: const EdgeInsets.only( + top: topPadding, right: rightPadding), child: IgnorePointer( child: LineChart( LineChartData( @@ -634,7 +759,8 @@ class InteractiveNoiseFloorChartState extends State ), // Marker overlay Padding( - padding: const EdgeInsets.only(top: topPadding, right: rightPadding), + padding: const EdgeInsets.only( + top: topPadding, right: rightPadding), child: IgnorePointer( child: CustomPaint( size: Size.infinite, @@ -676,13 +802,15 @@ class InteractiveNoiseFloorChartState extends State LineChartBarData _buildLineData(NoiseFloorSession session) { // Return cached data if session hasn't changed (prevents rebuilding during zoom) - if (_cachedLineData != null && _cachedSession == session && + if (_cachedLineData != null && + _cachedSession == session && _cachedSampleCount == session.samples.length) { return _cachedLineData!; } final spots = session.samples.map((s) { - final elapsed = s.timestamp.difference(session.startTime).inSeconds.toDouble(); + final elapsed = + s.timestamp.difference(session.startTime).inSeconds.toDouble(); return FlSpot(elapsed, s.noiseFloor.toDouble()); }).toList(); @@ -698,26 +826,26 @@ class InteractiveNoiseFloorChartState extends State return ((dbm - minY) / yRange).clamp(0.0, 1.0); } - // Smooth gradient with faded transitions + // Smooth gradient with faded transitions (palette-aware) final lineColors = [ - Colors.green, - Colors.green, - Colors.orange, - Colors.red, - Colors.red, + PingColors.noiseFloorGood, + PingColors.noiseFloorGood, + PingColors.noiseFloorMedium, + PingColors.noiseFloorBad, + PingColors.noiseFloorBad, ]; final fillColors = [ - Colors.green.withValues(alpha: 0.2), - Colors.green.withValues(alpha: 0.15), - Colors.orange.withValues(alpha: 0.12), - Colors.red.withValues(alpha: 0.1), - Colors.red.withValues(alpha: 0.08), + PingColors.noiseFloorGood.withValues(alpha: 0.2), + PingColors.noiseFloorGood.withValues(alpha: 0.15), + PingColors.noiseFloorMedium.withValues(alpha: 0.12), + PingColors.noiseFloorBad.withValues(alpha: 0.1), + PingColors.noiseFloorBad.withValues(alpha: 0.08), ]; final stops = [ 0.0, - yToStop(-100), // Start fading from green - yToStop(-90), // Orange in middle - yToStop(-80), // Fade to red + yToStop(-100), // Start fading from green + yToStop(-90), // Orange in middle + yToStop(-80), // Fade to red 1.0, ]; @@ -844,11 +972,12 @@ class InteractiveNoiseFloorChartState extends State runSpacing: 8, alignment: WrapAlignment.center, children: [ - _legendItem(context, Colors.green, 'TX Success'), - _legendItem(context, Colors.red, 'TX Fail'), - _legendItem(context, Colors.blue, 'RX'), - _legendItem(context, Colors.purple, 'DISC Success'), - _legendItem(context, Colors.grey, 'DISC Fail'), + _legendItem(context, PingColors.txSuccess, 'TX Success'), + _legendItem(context, PingColors.txFail, 'TX Fail'), + _legendItem(context, PingColors.rx, 'RX'), + _legendItem(context, PingColors.discSuccess, 'DISC Success'), + _legendItem(context, PingColors.traceSuccess, 'Trace Success'), + _legendItem(context, PingColors.noResponse, 'No Response'), ], ); } @@ -930,7 +1059,8 @@ class _MarkerPainter extends CustomPainter { if (visibleRange <= 0 || chartWidth <= 0 || chartHeight <= 0) return; for (final marker in session.markers) { - final elapsed = marker.timestamp.difference(session.startTime).inSeconds.toDouble(); + final elapsed = + marker.timestamp.difference(session.startTime).inSeconds.toDouble(); if (elapsed < minX || elapsed > maxX) continue; @@ -959,7 +1089,9 @@ class _MarkerPainter extends CustomPainter { double _interpolateNoiseFloor(double elapsedSeconds) { if (session.samples.isEmpty) return minY; - if (session.samples.length == 1) return session.samples.first.noiseFloor.toDouble(); + if (session.samples.length == 1) { + return session.samples.first.noiseFloor.toDouble(); + } NoiseFloorSample? before; NoiseFloorSample? after; @@ -967,7 +1099,8 @@ class _MarkerPainter extends CustomPainter { double afterElapsed = 0; for (final sample in session.samples) { - final sampleElapsed = sample.timestamp.difference(session.startTime).inSeconds.toDouble(); + final sampleElapsed = + sample.timestamp.difference(session.startTime).inSeconds.toDouble(); if (sampleElapsed <= elapsedSeconds) { before = sample; @@ -982,8 +1115,10 @@ class _MarkerPainter extends CustomPainter { if (before == null) return session.samples.first.noiseFloor.toDouble(); if (after == null) return before.noiseFloor.toDouble(); - final timeFraction = (elapsedSeconds - beforeElapsed) / (afterElapsed - beforeElapsed); - return before.noiseFloor + (after.noiseFloor - before.noiseFloor) * timeFraction; + final timeFraction = + (elapsedSeconds - beforeElapsed) / (afterElapsed - beforeElapsed); + return before.noiseFloor + + (after.noiseFloor - before.noiseFloor) * timeFraction; } @override diff --git a/lib/widgets/offline_mode_toggle.dart b/lib/widgets/offline_mode_toggle.dart new file mode 100644 index 00000000..af3ed03c --- /dev/null +++ b/lib/widgets/offline_mode_toggle.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../providers/app_state_provider.dart'; + +/// Shared offline mode toggle widget +/// Compact button style — icon + label, tappable to toggle with confirmation +class OfflineModeToggle extends StatelessWidget { + const OfflineModeToggle({super.key}); + + /// Handle offline mode toggle with progress dialog when connected + static Future handleOfflineModeToggle( + BuildContext context, + AppStateProvider appState, + bool currentOfflineMode, + bool isConnected, + ) async { + final newMode = !currentOfflineMode; + + // Always show confirmation dialog + final confirmed = await _showConfirmDialog(context, newMode); + if (confirmed != true || !context.mounted) return; + + // If connected, show progress dialog during mode switch + if (isConnected) { + final statusText = newMode + ? 'Switching to offline mode...' + : 'Switching to online mode...'; + + // Show non-dismissible progress dialog + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => PopScope( + canPop: false, + child: AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + statusText, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 14), + ), + ], + ), + ), + ), + ); + + // Perform the mode switch + final result = await appState.setOfflineMode(newMode); + + // Close the progress dialog (check if context is still valid) + if (context.mounted) { + Navigator.of(context).pop(); + } + + // Show error dialog if switch failed + if (!result.success && context.mounted) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Mode Switch Failed'), + content: Text( + result.error ?? 'An unknown error occurred', + style: const TextStyle(fontSize: 14), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } + } else { + // Not connected - simple toggle without dialog + await appState.setOfflineMode(newMode); + } + } + + /// Show confirmation dialog explaining what the mode does + static Future _showConfirmDialog( + BuildContext context, bool switchingToOffline) { + final title = + switchingToOffline ? 'Enable Offline Mode?' : 'Switch to Online Mode?'; + final icon = switchingToOffline ? Icons.cloud_off : Icons.cloud_done; + final iconColor = switchingToOffline ? Colors.orange : Colors.green; + final description = switchingToOffline + ? 'Wardrive data will be saved locally on your device instead of uploading to MeshMapper.\n\n' + 'This is useful when you have poor cell connectivity or the API is in maintenance.\n\n' + 'You can upload saved data later from the Settings tab.' + : 'Wardrive data will be uploaded to MeshMapper immediately as you drive.\n\n' + 'This requires an active internet connection.'; + final confirmLabel = switchingToOffline ? 'Go Offline' : 'Go Online'; + + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: [ + Icon(icon, color: iconColor, size: 22), + const SizedBox(width: 8), + Flexible(child: Text(title)), + ], + ), + content: Text( + description, + style: const TextStyle(fontSize: 14, height: 1.5), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + style: switchingToOffline + ? FilledButton.styleFrom(backgroundColor: Colors.orange) + : null, + child: Text(confirmLabel), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final appState = context.watch(); + final offlineMode = appState.offlineMode; + final isConnected = appState.isConnected; + + final color = offlineMode + ? (isDark ? Colors.orange.shade400 : Colors.orange.shade700) + : (isDark ? Colors.green.shade400 : Colors.green.shade700); + final bgColor = offlineMode + ? Colors.orange.withValues(alpha: 0.15) + : Colors.green.withValues(alpha: 0.15); + final borderColor = offlineMode + ? Colors.orange.withValues(alpha: 0.4) + : Colors.green.withValues(alpha: 0.4); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => handleOfflineModeToggle( + context, appState, offlineMode, isConnected), + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: borderColor), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + offlineMode ? Icons.cloud_off : Icons.cloud_queue, + size: 18, + color: color, + ), + const SizedBox(width: 8), + Text( + offlineMode ? 'Go Online' : 'Go Offline', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index ce7c6709..f1cf060d 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -5,6 +5,80 @@ import 'package:provider/provider.dart'; import '../providers/app_state_provider.dart'; import '../services/ping_service.dart'; import '../utils/debug_logger_io.dart'; +import 'repeater_picker_sheet.dart'; + +/// Fields the ping-control widgets depend on for their enabled/label state. +/// Used with `context.select` so the controls rebuild ONLY when one of these +/// changes — not on every GPS / noise-floor / battery `notifyListeners()` +/// (~1–2 Hz during wardriving). Timer (countdown) values are deliberately +/// excluded; they update via the inner `ListenableBuilder(timerListenable)` +/// in each widget. Keep this in sync with the `appState.*` reads in the build +/// bodies below — a missing field means a button can go stale while idle. +typedef _ControlsDeps = ({ + PingValidation pingValidation, + PingValidation manualValidation, + PingValidation autoValidation, + bool autoPingEnabled, + AutoMode autoMode, + bool isTargetedModeRunning, + bool hybridModeEnabled, + bool isPendingDisable, + bool isPingSending, + bool isAutoPingStarting, + bool isPingInProgress, + bool isConnected, + bool offlineMode, + bool txAllowed, + bool externalAntenna, + bool externalAntennaSet, + bool isPowerSet, + bool floodTrafficEnabled, + bool hasTargetRepeaterId, +}); + +_ControlsDeps _controlsDepsOf(AppStateProvider s) { + final prefs = s.preferences; + final targetId = s.targetRepeaterId; + return ( + pingValidation: s.pingValidation, + manualValidation: s.manualPingValidation, + autoValidation: s.autoModeValidation, + autoPingEnabled: s.autoPingEnabled, + autoMode: s.autoMode, + isTargetedModeRunning: s.isTargetedModeRunning, + hybridModeEnabled: prefs.hybridModeEnabled, + isPendingDisable: s.isPendingDisable, + isPingSending: s.isPingSending, + isAutoPingStarting: s.isAutoPingStarting, + isPingInProgress: s.isPingInProgress, + isConnected: s.isConnected, + offlineMode: s.offlineMode, + txAllowed: s.txAllowed, + externalAntenna: prefs.externalAntenna, + externalAntennaSet: prefs.externalAntennaSet, + isPowerSet: + prefs.autoPowerSet || prefs.powerLevelSet || s.deviceModel != null, + floodTrafficEnabled: s.floodTrafficEnabled, + hasTargetRepeaterId: targetId != null && targetId.isNotEmpty, + ); +} + +/// Subset of provider state the Trace Mode section depends on. +typedef _TargetedDeps = ({ + bool isTargetedModeRunning, + int traceHopBytes, + String? targetRepeaterId, + bool isConnected, + bool hasRepeaters, +}); + +_TargetedDeps _targetedDepsOf(AppStateProvider s) => ( + isTargetedModeRunning: s.isTargetedModeRunning, + traceHopBytes: s.traceHopBytes, + targetRepeaterId: s.targetRepeaterId, + isConnected: s.isConnected, + hasRepeaters: s.repeaters.isNotEmpty, + ); /// Modern ping control panel with icon-based buttons and animated status class PingControls extends StatelessWidget { @@ -12,233 +86,331 @@ class PingControls extends StatelessWidget { @override Widget build(BuildContext context) { - final appState = context.watch(); - final validation = appState.pingValidation; - final manualValidation = appState.manualPingValidation; // Manual ping validation (no distance check) - final autoValidation = appState.autoModeValidation; - final canPingManual = manualValidation == PingValidation.valid; // For Send Ping button - final canStartAuto = autoValidation == PingValidation.valid; - final isActiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.active; - final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; - final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; - final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; - final hybridEnabled = appState.preferences.hybridModeEnabled; - final isPendingDisable = appState.isPendingDisable; // Disable pending, waiting for RX window to complete - final cooldownActive = appState.cooldownTimer.isRunning; // Shared cooldown after disabling Active Mode - final cooldownRemaining = appState.cooldownTimer.remainingSec; - final manualCooldownActive = appState.manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) - final manualCooldownRemaining = appState.manualPingCooldownTimer.remainingSec; - final rxWindowActive = appState.rxWindowTimer.isRunning; // RX listening window after ping - final rxWindowRemaining = appState.rxWindowTimer.remainingSec; - final isPingSending = appState.isPingSending; // True immediately when manual ping button clicked - final isPingInProgress = appState.isPingInProgress; // True during entire ping + RX window (includes auto pings) - final autoPingWaiting = appState.autoPingTimer.isRunning; // Waiting for next auto ping - final autoPingRemaining = appState.autoPingTimer.remainingSec; - final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; // Discovery listening window countdown (Passive Mode) - final discoveryWindowRemaining = appState.discoveryWindowTimer.remainingSec; - - // TX is blocked when offline mode is active and connected - final txBlockedByOffline = appState.offlineMode && appState.isConnected; - - // TX not allowed when API says zone is at TX capacity - final txNotAllowed = appState.isConnected && !appState.txAllowed; - - // Determine blocking reason for status hint (in priority order) - // Skip antenna hint - the antenna selector already shows this - String? blockingHint; - IconData? blockingIcon; - Color? blockingColor; - - final prefs = appState.preferences; - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; - - if (!appState.isConnected) { - // Don't show hint when disconnected - buttons are obviously disabled - } else if (!prefs.externalAntennaSet) { - blockingHint = 'Select antenna option'; - blockingIcon = Icons.settings_input_antenna; - blockingColor = Colors.orange; - } else if (!isPowerSet) { - blockingHint = 'Select power level in Settings'; - blockingIcon = Icons.bolt; - blockingColor = Colors.orange; - } else if (validation == PingValidation.noGpsLock) { - blockingHint = 'Waiting for GPS lock...'; - blockingIcon = Icons.gps_off; - blockingColor = Colors.blue; - } else if (validation == PingValidation.gpsInaccurate) { - blockingHint = 'GPS accuracy too low'; - blockingIcon = Icons.gps_not_fixed; - blockingColor = Colors.orange; - } else if (validation == PingValidation.outsideGeofence) { - blockingHint = 'Outside service area'; - blockingIcon = Icons.wrong_location; - blockingColor = Colors.red; - } - // Note: cooldown and tooClose are shown on button itself - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Action buttons row - Row( + // Rebuild only when control-relevant state changes — NOT on every GPS / + // noise-floor / battery notify. Countdown values update via the inner + // ListenableBuilder(timerListenable) below, so they're excluded here. + context.select(_controlsDepsOf); + final appState = context.read(); + return ListenableBuilder( + listenable: appState.timerListenable, + builder: (_, __) { + final validation = appState.pingValidation; + final manualValidation = appState + .manualPingValidation; // Manual ping validation (no distance check) + final autoValidation = appState.autoModeValidation; + final canPingManual = + manualValidation == PingValidation.valid; // For Send Ping button + final canStartAuto = autoValidation == PingValidation.valid; + final isActiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.active; + final isPassiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.passive; + final isHybridModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; + final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; + final isTargetedRunning = appState.isTargetedModeRunning; + final hybridEnabled = appState.preferences.hybridModeEnabled; + final isPendingDisable = appState + .isPendingDisable; // Disable pending, waiting for RX window to complete + final isAutoStarting = appState + .isAutoPingStarting; // True while an auto mode is starting (pre-first-notify) + final cooldownActive = appState.cooldownTimer + .isRunning; // Shared cooldown after disabling Active Mode + final cooldownRemaining = appState.cooldownTimer.remainingSec; + final manualCooldownActive = appState.manualPingCooldownTimer + .isRunning; // Manual ping cooldown (15 seconds) + final manualCooldownRemaining = + appState.manualPingCooldownTimer.remainingSec; + final rxWindowActive = + appState.rxWindowTimer.isRunning; // RX listening window after ping + final rxWindowRemaining = appState.rxWindowTimer.remainingSec; + final isPingSending = appState + .isPingSending; // True immediately when manual ping button clicked + final isPingInProgress = appState + .isPingInProgress; // True during entire ping + RX window (includes auto pings) + final autoPingWaiting = + appState.autoPingTimer.isRunning; // Waiting for next auto ping + final autoPingRemaining = appState.autoPingTimer.remainingSec; + final autoPingSkipped = appState.autoPingTimer.skipReason != + null; // Last ping was skipped (e.g. distance) + final discoveryWindowActive = appState.discoveryWindowTimer + .isRunning; // Discovery listening window countdown (Passive Mode) + final discoveryWindowRemaining = + appState.discoveryWindowTimer.remainingSec; + + // TX is blocked when offline mode is active and connected + final txBlockedByOffline = appState.offlineMode && appState.isConnected; + + // TX not allowed when API says zone is at TX capacity + final txNotAllowed = appState.isConnected && !appState.txAllowed; + + // Determine blocking reason for status hint (in priority order) + // Skip antenna hint - the antenna selector already shows this + String? blockingHint; + IconData? blockingIcon; + Color? blockingColor; + + final prefs = appState.preferences; + final isPowerSet = prefs.autoPowerSet || + prefs.powerLevelSet || + appState.deviceModel != null; + + if (!appState.isConnected) { + // Don't show hint when disconnected - buttons are obviously disabled + } else if (!prefs.externalAntennaSet) { + blockingHint = 'Select antenna option'; + blockingIcon = Icons.settings_input_antenna; + blockingColor = Colors.orange; + } else if (!isPowerSet) { + blockingHint = 'Select power level in Connect tab'; + blockingIcon = Icons.bolt; + blockingColor = Colors.orange; + } else if (validation == PingValidation.noGpsLock) { + blockingHint = 'Waiting for GPS lock...'; + blockingIcon = Icons.gps_off; + blockingColor = Colors.blue; + } else if (validation == PingValidation.gpsInaccurate) { + blockingHint = 'GPS accuracy too low'; + blockingIcon = Icons.gps_not_fixed; + blockingColor = Colors.orange; + } else if (validation == PingValidation.outsideGeofence) { + blockingHint = 'Outside service area'; + blockingIcon = Icons.wrong_location; + blockingColor = Colors.red; + } + // Note: cooldown and tooClose are shown on button itself + + final floodTrafficVisible = appState.floodTrafficEnabled; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Send Ping button - // State flow: "Send Ping" → "Sending..." → "Listening Xs" → "Cooldown Xs" → "Send Ping" - // Manual pings use 15-second cooldown, no distance requirement - // When Active/Passive Mode is running, just shows "Send Ping" (disabled) - Expanded( - child: _ActionButton( - icon: Icons.cell_tower, - label: txBlockedByOffline - ? 'TX Disabled' - : txNotAllowed - ? 'Zone Full' - : isTxModeRunning - ? 'Send Ping' // Just disabled when Active/Hybrid Mode is running - : isPingSending - ? 'Sending...' - : rxWindowActive - ? 'Listening ${rxWindowRemaining}s' // Manual ping listening (works during Passive Mode too) - : manualCooldownActive - ? 'Cooldown ${manualCooldownRemaining}s' // Manual ping 15-second cooldown - : discoveryWindowActive - ? 'Cooldown ${discoveryWindowRemaining}s' // Cooldown during Passive Mode listening - : cooldownActive - ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled - : 'Send Ping', - color: const Color(0xFF0EA5E9), // sky-500 - enabled: canPingManual && !isTxModeRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && - !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable, - isActive: (isPingSending || rxWindowActive) && !isTxModeRunning, // Only active during manual ping flow - onPressed: () => _sendPing(context, appState), - showCooldown: false, // No longer needed - countdown shown in label - subtitle: txBlockedByOffline ? 'Offline Mode' : txNotAllowed ? 'Passive Only' : null, // No "Move Xm" - manual pings have no distance requirement - subtitleColor: txBlockedByOffline ? Colors.orange : txNotAllowed ? Colors.red : null, - ), - ), - const SizedBox(width: 10), - - // Active/Hybrid Mode button (toggle) - // When hybridEnabled: shows as "Hybrid Mode" with compare_arrows icon - // When ON: shows "Sending..."/"Discovering..." → "Listening Xs" → "Next ping Xs" cycle - // When OFF after being ON: shows "Cooldown Xs" like other buttons - // During manual ping: shows "Cooldown Xs" (disabled) - Expanded( - child: _ActionButton( - icon: hybridEnabled ? Icons.compare_arrows : Icons.sensors, - label: txBlockedByOffline - ? 'TX Disabled' - : txNotAllowed - ? 'Zone Full' - : isPendingDisable - ? (rxWindowActive - ? 'Stopping ${rxWindowRemaining}s' - : discoveryWindowActive - ? 'Stopping ${discoveryWindowRemaining}s' - : 'Stopping...') - : isTxModeRunning - ? (isPingInProgress && !rxWindowActive && !discoveryWindowActive - ? 'Sending...' - : discoveryWindowActive - ? 'Listening ${discoveryWindowRemaining}s' // Discovery listening window - : rxWindowActive - ? 'Listening ${rxWindowRemaining}s' // TX RX window - : autoPingWaiting - ? 'Next ping ${autoPingRemaining}s' - : hybridEnabled ? 'Hybrid Mode' : 'Active Mode') - : rxWindowActive - ? 'Cooldown ${rxWindowRemaining}s' - : cooldownActive - ? 'Cooldown ${cooldownRemaining}s' - : hybridEnabled ? 'Hybrid Mode' : 'Active Mode', - color: isPendingDisable - ? Colors.orange - : isTxModeRunning + // Action buttons row + Row( + children: [ + if (!txNotAllowed && floodTrafficVisible) ...[ + // Send Ping button + // State flow: "Send Ping" → "Sending..." → "Listening Xs" → "Cooldown Xs" → "Send Ping" + // Manual pings use 15-second cooldown, no distance requirement + // When Active/Passive Mode is running, just shows "Send Ping" (disabled) + Expanded( + child: _ActionButton( + icon: Icons.cell_tower, + label: txBlockedByOffline + ? 'TX Disabled' + : txNotAllowed + ? 'Zone Full' + : isTxModeRunning + ? 'Send Ping' // Just disabled when Active/Hybrid Mode is running + : isPingSending + ? 'Sending...' + : rxWindowActive + ? 'Listening ${rxWindowRemaining}s' // Manual ping listening (works during Passive Mode too) + : manualCooldownActive + ? 'Cooldown ${manualCooldownRemaining}s' // Manual ping 15-second cooldown + : discoveryWindowActive + ? 'Cooldown ${discoveryWindowRemaining}s' // Cooldown during Passive Mode listening + : cooldownActive + ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled + : 'Send Ping', + color: const Color(0xFF0EA5E9), // sky-500 + enabled: canPingManual && + // Grey Send Ping while an auto mode is starting so the + // single-ping icon doesn't show until the first auto ping + // fires (matches active/passive gating) (#389) + !isAutoStarting && + !isTxModeRunning && + !isTargetedRunning && + !cooldownActive && + !manualCooldownActive && + !txBlockedByOffline && + !txNotAllowed && + !rxWindowActive && + !isPingSending && + !discoveryWindowActive && + !isPendingDisable, + isActive: (isPingSending || rxWindowActive) && + !isTxModeRunning, // Only active during manual ping flow + onPressed: () => _sendPing(context, appState), + showCooldown: + false, // No longer needed - countdown shown in label + subtitle: txBlockedByOffline + ? 'Offline Mode' + : txNotAllowed + ? 'Passive Only' + : null, // No "Move Xm" - manual pings have no distance requirement + subtitleColor: txBlockedByOffline + ? Colors.orange + : txNotAllowed + ? Colors.red + : null, + ), + ), + const SizedBox(width: 10), + + // Active/Hybrid Mode button (toggle) + // When hybridEnabled: shows as "Hybrid Mode" with compare_arrows icon + // When ON: shows "Sending..."/"Discovering..." → "Listening Xs" → "Next ping Xs" cycle + // When OFF after being ON: shows "Cooldown Xs" like other buttons + // During manual ping: shows "Cooldown Xs" (disabled) + Expanded( + child: _ActionButton( + icon: + hybridEnabled ? Icons.compare_arrows : Icons.sensors, + label: txBlockedByOffline + ? 'TX Disabled' + : txNotAllowed + ? 'Zone Full' + : isPendingDisable + ? (rxWindowActive + ? 'Stopping ${rxWindowRemaining}s' + : discoveryWindowActive + ? 'Stopping ${discoveryWindowRemaining}s' + : 'Stopping...') + : isTxModeRunning + ? (isPingInProgress && + !rxWindowActive && + !discoveryWindowActive + ? 'Sending...' + : discoveryWindowActive + ? 'Listening ${discoveryWindowRemaining}s' // Discovery listening window + : rxWindowActive + ? 'Listening ${rxWindowRemaining}s' // TX RX window + : autoPingWaiting + ? (autoPingSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Next ping ${autoPingRemaining}s') + : hybridEnabled + ? 'Hybrid Mode' + : 'Active Mode') + : rxWindowActive + ? 'Cooldown ${rxWindowRemaining}s' + : cooldownActive + ? 'Cooldown ${cooldownRemaining}s' + : hybridEnabled + ? 'Hybrid Mode' + : 'Active Mode', + color: isPendingDisable + ? Colors.orange + : isTxModeRunning + ? const Color(0xFF22C55E) // green-500 + : const Color(0xFF6366F1), // indigo-500 + enabled: !isPendingDisable && + !isTargetedRunning && + !isAutoStarting && + ((isTxModeRunning || + (canStartAuto && + !isPassiveModeRunning && + !cooldownActive && + !isPingSending && + !rxWindowActive)) && + !txBlockedByOffline && + !txNotAllowed), + isActive: isPendingDisable || isTxModeRunning, + onPressed: () => hybridEnabled + ? _toggleHybridAuto(context, appState) + : _toggleTxRxAuto(context, appState), + showCooldown: false, + subtitle: txBlockedByOffline + ? 'Offline Mode' + : txNotAllowed + ? 'Passive Only' + : (isPendingDisable ? 'Stopping' : null), + subtitleColor: txBlockedByOffline + ? Colors.orange + : txNotAllowed + ? Colors.red + : Colors.orange, + ), + ), + const SizedBox(width: 10), + ], + + // Passive Mode button (toggle) + // When ON: shows "Listening..." → "Next Disc Xs" cycle + // When OFF: returns to normal, Active/Hybrid Mode re-enables immediately + // Disabled during manual ping countdown phases, shows "Cooldown Xs" + // When Active/Hybrid Mode is running, just shows "Passive Mode" (disabled, no countdown) + Expanded( + child: _ActionButton( + icon: Icons.hearing, + label: isPassiveModeRunning + ? (discoveryWindowActive + ? 'Listening ${discoveryWindowRemaining}s' // During discovery listening window + : autoPingWaiting + ? (autoPingSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Next Disc ${autoPingRemaining}s') // Waiting for next discovery + : 'Passive Mode') // Initial state before first discovery + : isTxModeRunning || isPendingDisable + ? 'Passive Mode' // Just disabled when Active/Hybrid Mode is running or stopping + : rxWindowActive + ? 'Cooldown ${rxWindowRemaining}s' // During manual ping listening + : cooldownActive + ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled + : 'Passive Mode', + color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: !isPendingDisable && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), - isActive: isPendingDisable || isTxModeRunning, - onPressed: () => hybridEnabled ? _toggleHybridAuto(context, appState) : _toggleTxRxAuto(context, appState), - showCooldown: false, - subtitle: txBlockedByOffline ? 'Offline Mode' : txNotAllowed ? 'Passive Only' : (isPendingDisable ? 'Stopping' : null), - subtitleColor: txBlockedByOffline ? Colors.orange : txNotAllowed ? Colors.red : Colors.orange, - ), - ), - const SizedBox(width: 10), - - // Passive Mode button (toggle) - // When ON: shows "Listening..." → "Next Disc Xs" cycle - // When OFF: returns to normal, Active/Hybrid Mode re-enables immediately - // Disabled during manual ping countdown phases, shows "Cooldown Xs" - // When Active/Hybrid Mode is running, just shows "Passive Mode" (disabled, no countdown) - Expanded( - child: _ActionButton( - icon: Icons.hearing, - label: isPassiveModeRunning - ? (discoveryWindowActive - ? 'Listening ${discoveryWindowRemaining}s' // During discovery listening window - : autoPingWaiting - ? 'Next Disc ${autoPingRemaining}s' // Waiting for next discovery - : 'Passive Mode') // Initial state before first discovery - : isTxModeRunning || isPendingDisable - ? 'Passive Mode' // Just disabled when Active/Hybrid Mode is running or stopping - : rxWindowActive - ? 'Cooldown ${rxWindowRemaining}s' // During manual ping listening - : cooldownActive - ? 'Cooldown ${cooldownRemaining}s' // After Active/Hybrid Mode disabled - : 'Passive Mode', - color: isPassiveModeRunning - ? const Color(0xFF22C55E) // green-500 - : const Color(0xFF6366F1), // indigo-500 - enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isPendingDisable && - !isPingSending && !rxWindowActive && !cooldownActive && - prefs.externalAntennaSet && isPowerSet), - isActive: isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting), // Active during listening/waiting phases - onPressed: () => _toggleRxAuto(context, appState), - ), - ), - ], - ), - - // Status hint area - only show when there's a hint - if (blockingHint != null) - Padding( - padding: const EdgeInsets.only(top: 6, bottom: 2), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(blockingIcon, size: 14, color: blockingColor), - const SizedBox(width: 6), - Text( - blockingHint, - style: TextStyle( - fontSize: 12, - color: blockingColor, - fontWeight: FontWeight.w500, + enabled: isPassiveModeRunning || + (appState.isConnected && + !isTxModeRunning && + !isTargetedRunning && + !isPendingDisable && + !isAutoStarting && + !isPingSending && + !rxWindowActive && + !cooldownActive && + prefs.externalAntennaSet && + isPowerSet), + isActive: isPassiveModeRunning && + (discoveryWindowActive || + autoPingWaiting), // Active during listening/waiting phases + onPressed: () => _toggleRxAuto(context, appState), ), ), ], ), - ) - else - const SizedBox(height: 8), - // Offline Mode and Sound toggles row - const Row( - children: [ - // Offline Mode toggle (expanded) - Expanded(child: _OfflineModeToggle()), - SizedBox(width: 8), - // Sound toggle (compact, right side) - _SoundToggle(), + // Status hint area - only show when there's a hint + if (blockingHint != null) + Padding( + padding: const EdgeInsets.only(top: 6, bottom: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(blockingIcon, size: 14, color: blockingColor), + const SizedBox(width: 6), + Text( + blockingHint, + style: TextStyle( + fontSize: 12, + color: blockingColor, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ) + else + const SizedBox(height: 8), + + // Targeted Ping controls + _TargetedPingSection( + isAnyModeRunning: isActiveModeRunning || + isPassiveModeRunning || + isHybridModeRunning, + cooldownActive: cooldownActive, + cooldownRemaining: cooldownRemaining, + ), ], - ), - ], + ); + }, ); } - Future _sendPing(BuildContext context, AppStateProvider appState) async { + Future _sendPing( + BuildContext context, AppStateProvider appState) async { HapticFeedback.mediumImpact(); final success = await appState.sendPing(); @@ -248,17 +420,20 @@ class PingControls extends StatelessWidget { } } - Future _toggleTxRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleTxRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.active); } - Future _toggleHybridAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleHybridAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.hybrid); } - Future _toggleRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.passive); } @@ -273,8 +448,8 @@ class _ActionButton extends StatefulWidget { final bool isActive; final bool showCooldown; final VoidCallback onPressed; - final String? subtitle; // Optional subtitle text (e.g., "Move 5m") - final Color? subtitleColor; // Optional subtitle color + final String? subtitle; // Optional subtitle text (e.g., "Move 5m") + final Color? subtitleColor; // Optional subtitle color const _ActionButton({ required this.icon, @@ -292,236 +467,114 @@ class _ActionButton extends StatefulWidget { State<_ActionButton> createState() => _ActionButtonState(); } -class _ActionButtonState extends State<_ActionButton> - with SingleTickerProviderStateMixin { - late AnimationController _pulseController; - late Animation _pulseAnimation; - - @override - void initState() { - super.initState(); - _pulseController = AnimationController( - duration: const Duration(milliseconds: 1200), - vsync: this, - ); - // Pulse opacity from 0.3 to 0.6 for a subtle glow effect - _pulseAnimation = Tween(begin: 0.15, end: 0.35).animate( - CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), - ); - - if (widget.isActive) { - _pulseController.repeat(reverse: true); - } - } - - @override - void didUpdateWidget(_ActionButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.isActive && !oldWidget.isActive) { - _pulseController.repeat(reverse: true); - } else if (!widget.isActive && oldWidget.isActive) { - _pulseController.stop(); - _pulseController.reset(); - } - } - - @override - void dispose() { - _pulseController.dispose(); - super.dispose(); - } - +class _ActionButtonState extends State<_ActionButton> { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; // Use color when enabled, active (RX listening), or during cooldown // This prevents the button from going grey during cooldown final showColor = widget.enabled || widget.isActive || widget.showCooldown; - final effectiveColor = showColor ? widget.color : colorScheme.onSurfaceVariant; + final effectiveColor = + showColor ? widget.color : colorScheme.onSurfaceVariant; final borderOpacity = widget.isActive ? 0.6 : 0.3; - - return AnimatedBuilder( - animation: _pulseAnimation, - builder: (context, child) { - // Use animated opacity for active state background - final bgOpacity = widget.isActive ? _pulseAnimation.value : 0.12; - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: widget.enabled ? widget.onPressed : null, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 14), - decoration: BoxDecoration( - color: effectiveColor.withValues(alpha: bgOpacity), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: effectiveColor.withValues(alpha: borderOpacity), - width: widget.isActive ? 2 : 1, - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Icon with indicator dot - SizedBox( - height: 30, - child: Stack( - alignment: Alignment.center, - clipBehavior: Clip.none, - children: [ - // Main icon - Icon( - widget.icon, - size: 26, - color: showColor - ? effectiveColor - : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), - ), - // Active indicator dot - if (widget.isActive) - Positioned( - top: 0, - right: -6, - child: Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: const Color(0xFF22C55E), - shape: BoxShape.circle, - border: Border.all( - color: colorScheme.surface, - width: 2, - ), - ), - ), - ), - ], - ), - ), - const SizedBox(height: 4), - // Label - always same height - Text( - widget.label, - style: TextStyle( - fontSize: 11, - fontWeight: widget.isActive ? FontWeight.w600 : FontWeight.w500, - color: showColor - ? (widget.isActive ? effectiveColor : colorScheme.onSurface) - : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), - ), - ), - // Active status text OR subtitle - always reserve space - SizedBox( - height: 12, - child: widget.isActive - ? const Text( - 'Active', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.w600, - color: Color(0xFF22C55E), - ), - ) - : widget.subtitle != null - ? Text( - widget.subtitle!, - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.w500, - color: widget.subtitleColor ?? Colors.orange.shade600, - ), - ) - : null, - ), - ], - ), - ), - ), - ); - }, - ); - } -} - -/// Compact sound notification toggle - matches height of _OfflineModeToggle -class _SoundToggle extends StatelessWidget { - const _SoundToggle(); - - @override - Widget build(BuildContext context) { - final appState = context.watch(); - final soundEnabled = appState.isSoundEnabled; - final colorScheme = Theme.of(context).colorScheme; - final isDark = Theme.of(context).brightness == Brightness.dark; + // Static active-state background opacity. This was a repeating pulse + // animation, removed because a .repeat() AnimationController kept the GPU + // rendering at the display refresh rate for the entire wardriving session. + // The button still reads as "active" via color, the dot, and the text. + final bgOpacity = widget.isActive ? 0.25 : 0.12; return Material( color: Colors.transparent, child: InkWell( - onTap: () => appState.toggleSoundEnabled(), - borderRadius: BorderRadius.circular(10), + onTap: widget.enabled ? widget.onPressed : null, + borderRadius: BorderRadius.circular(12), child: Container( - // Match _OfflineModeToggle: padding 10v + icon container (6+18+6) + text adds more height - // _OfflineModeToggle content: icon 30px, text column ~34px, toggle 26px - // Use same padding and let IntrinsicHeight from Row handle matching - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + padding: const EdgeInsets.symmetric(vertical: 14), decoration: BoxDecoration( - color: soundEnabled - ? Colors.blue.withValues(alpha: 0.15) - : colorScheme.onSurface.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(10), + color: effectiveColor.withValues(alpha: bgOpacity), + borderRadius: BorderRadius.circular(12), border: Border.all( - color: soundEnabled - ? Colors.blue.withValues(alpha: 0.4) - : colorScheme.outline.withValues(alpha: 0.2), + color: effectiveColor.withValues(alpha: borderOpacity), + width: widget.isActive ? 2 : 1, ), ), - child: Row( + child: Column( mainAxisSize: MainAxisSize.min, children: [ - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: soundEnabled - ? Colors.blue.withValues(alpha: 0.2) - : colorScheme.onSurface.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(6), + // Icon with indicator dot + SizedBox( + height: 30, + child: Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + // Main icon + Icon( + widget.icon, + size: 26, + color: showColor + ? effectiveColor + : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + ), + // Active indicator dot + if (widget.isActive) + Positioned( + top: 0, + right: -6, + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: const Color(0xFF22C55E), + shape: BoxShape.circle, + border: Border.all( + color: colorScheme.surface, + width: 2, + ), + ), + ), + ), + ], ), - child: Icon( - soundEnabled ? Icons.volume_up : Icons.volume_off, - size: 18, - color: soundEnabled - ? (isDark ? Colors.blue.shade400 : Colors.blue.shade700) - : colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 4), + // Label - always same height + Text( + widget.label, + style: TextStyle( + fontSize: 11, + fontWeight: + widget.isActive ? FontWeight.w600 : FontWeight.w500, + color: showColor + ? (widget.isActive + ? effectiveColor + : colorScheme.onSurface) + : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), ), - const SizedBox(width: 8), - // Add text column to match offline mode toggle height - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Sound', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: soundEnabled - ? (isDark ? Colors.blue.shade300 : Colors.blue.shade800) - : colorScheme.onSurface, - ), - ), - Text( - soundEnabled ? 'On' : 'Off', - style: TextStyle( - fontSize: 11, - color: soundEnabled - ? (isDark ? Colors.blue.shade400 : Colors.blue.shade600) - : colorScheme.onSurfaceVariant, - ), - ), - ], + // Active status text OR subtitle - always reserve space + SizedBox( + height: 12, + child: widget.isActive + ? const Text( + 'Active', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w600, + color: Color(0xFF22C55E), + ), + ) + : widget.subtitle != null + ? Text( + widget.subtitle!, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w500, + color: widget.subtitleColor ?? + Colors.orange.shade600, + ), + ) + : null, ), ], ), @@ -531,280 +584,263 @@ class _SoundToggle extends StatelessWidget { } } -/// Compact offline mode toggle matching app design language -class _OfflineModeToggle extends StatelessWidget { - const _OfflineModeToggle(); - - // Offline mode is now enabled - static const bool _isEnabled = true; - - /// Handle offline mode toggle with progress dialog when connected - static Future _handleOfflineModeToggle( - BuildContext context, - AppStateProvider appState, - bool currentOfflineMode, - bool isConnected, - ) async { - final newMode = !currentOfflineMode; - - // If connected, show progress dialog during mode switch - if (isConnected) { - final statusText = newMode - ? 'Switching to offline mode...' - : 'Switching to online mode...'; - - // Show non-dismissible progress dialog - showDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) => PopScope( - canPop: false, - child: AlertDialog( - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 16), - Text( - statusText, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 14), - ), - ], - ), - ), - ), - ); +/// Targeted Ping controls - hex text field + start/stop button +class _TargetedPingSection extends StatefulWidget { + final bool isAnyModeRunning; + final bool cooldownActive; + final int cooldownRemaining; + final bool compact; + + const _TargetedPingSection({ + required this.isAnyModeRunning, + required this.cooldownActive, + required this.cooldownRemaining, + this.compact = false, + }); - // Perform the mode switch - final result = await appState.setOfflineMode(newMode); + @override + State<_TargetedPingSection> createState() => _TargetedPingSectionState(); +} - // Close the progress dialog (check if context is still valid) - if (context.mounted) { - Navigator.of(context).pop(); - } +class _TargetedPingSectionState extends State<_TargetedPingSection> { + final _controller = TextEditingController(); + bool _isStarting = false; - // Show error dialog if switch failed - if (!result.success && context.mounted) { - showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Mode Switch Failed'), - content: Text( - result.error ?? 'An unknown error occurred', - style: const TextStyle(fontSize: 14), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('OK'), - ), - ], - ), - ); + @override + void initState() { + super.initState(); + // Restore any previously set target ID + WidgetsBinding.instance.addPostFrameCallback((_) { + final appState = context.read(); + final existing = appState.targetRepeaterId; + if (existing != null && + existing.isNotEmpty && + _controller.text != existing) { + _controller.text = existing; } - } else { - // Not connected - simple toggle without dialog - await appState.setOfflineMode(newMode); - } + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _showRepeaterPicker() async { + final appState = context.read(); + final repeater = await showRepeaterPicker(context); + if (repeater == null || !mounted) return; + + final maxLen = appState.traceHopBytes * 2; + final trimmed = repeater.hexId.length >= maxLen + ? repeater.hexId.substring(0, maxLen).toUpperCase() + : repeater.hexId.toUpperCase(); + _controller.text = trimmed; + appState.setTargetRepeaterId(trimmed); + setState(() {}); } @override Widget build(BuildContext context) { + // Rebuild only when Trace-relevant state changes (not on GPS/noise/battery). + context.select(_targetedDepsOf); + final appState = context.read(); final colorScheme = Theme.of(context).colorScheme; - final isDark = Theme.of(context).brightness == Brightness.dark; - - // When disabled, always show as "off" state - if (!_isEnabled) { - return Opacity( - opacity: 0.5, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + return ListenableBuilder( + listenable: appState.timerListenable, + builder: (_, __) { + final isTargetedRunning = appState.isTargetedModeRunning; + final maxLen = appState.traceHopBytes * 2; + + // Sync controller when provider clears target (e.g. trace bytes changed) + if (appState.targetRepeaterId == null && _controller.text.isNotEmpty) { + _controller.clear(); + } + + // Determine if the start button should be enabled + final hexText = _controller.text.trim(); + final isValidHex = hexText.isNotEmpty && + hexText.length == maxLen && + RegExp(r'^[0-9a-fA-F]+$').hasMatch(hexText); + final canStart = isValidHex && + !widget.isAnyModeRunning && + !isTargetedRunning && + !widget.cooldownActive && + appState.isConnected; + + // Status text for when targeted mode is running + String? statusText; + if (isTargetedRunning) { + final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; + final discoveryRemaining = appState.discoveryWindowTimer.remainingSec; + final autoPingWaiting = appState.autoPingTimer.isRunning; + final autoPingRemaining = appState.autoPingTimer.remainingSec; + + if (discoveryWindowActive) { + statusText = 'Listening ${discoveryRemaining}s'; + } else if (autoPingWaiting) { + statusText = appState.autoPingTimer.skipReason != null + ? 'Skipped ${autoPingRemaining}s' + : 'Next in ${autoPingRemaining}s'; + } + } + + final isEnabled = (canStart || isTargetedRunning) && !_isStarting; + final buttonColor = (isTargetedRunning || _isStarting) + ? const Color(0xFF22C55E) // green-500 when running/starting + : Colors.cyan; + final effectiveColor = + isEnabled ? buttonColor : colorScheme.onSurfaceVariant; + + return Container( decoration: BoxDecoration( - color: colorScheme.onSurface.withValues(alpha: 0.05), + color: effectiveColor.withValues( + alpha: isTargetedRunning ? 0.15 : 0.08), borderRadius: BorderRadius.circular(10), border: Border.all( - color: colorScheme.outline.withValues(alpha: 0.2), + color: effectiveColor.withValues( + alpha: isTargetedRunning ? 0.5 : 0.25), + width: isTargetedRunning ? 1.5 : 1, ), ), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( children: [ - // Icon - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: colorScheme.onSurface.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - Icons.cloud_queue, - size: 18, - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 12), - // Label and "Coming soon" + // Targeted button Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Offline Mode', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: colorScheme.onSurfaceVariant, - ), - ), - Text( - 'Coming soon', - style: TextStyle( - fontSize: 11, - fontStyle: FontStyle.italic, - color: colorScheme.onSurfaceVariant, + child: GestureDetector( + onTap: isEnabled + ? () async { + HapticFeedback.lightImpact(); + if (!isTargetedRunning) { + setState(() => _isStarting = true); + appState.setTargetRepeaterId( + _controller.text.trim().toUpperCase()); + } + await appState.toggleAutoPing(AutoMode.targeted); + if (mounted) setState(() => _isStarting = false); + } + : null, + behavior: HitTestBehavior.opaque, + child: Row( + children: [ + Icon( + Icons.route, + size: 18, + color: effectiveColor, ), - ), - ], - ), - ), - // Toggle indicator (always off) - Container( - width: 44, - height: 26, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(13), - color: isDark ? const Color(0xFF475569) : const Color(0xFFCBD5E1), // slate-600/300 - ), - child: Align( - alignment: Alignment.centerLeft, - child: Container( - width: 22, - height: 22, - margin: const EdgeInsets.all(2), - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors.white, - ), + if (!widget.compact) ...[ + const SizedBox(width: 8), + Expanded( + child: Text( + _isStarting + ? 'Starting...' + : isTargetedRunning + ? (statusText ?? 'Stop') + : widget.cooldownActive + ? 'Cooldown ${widget.cooldownRemaining}s' + : 'Trace Mode', + style: TextStyle( + fontSize: 13, + fontWeight: isTargetedRunning + ? FontWeight.w600 + : FontWeight.w500, + color: isEnabled + ? colorScheme.onSurface + : colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ], ), ), ), - ], - ), - ), - ); - } - - // Original implementation when enabled - final appState = context.watch(); - final offlineMode = appState.offlineMode; - final offlinePingCount = appState.offlinePingCount; - final isConnected = appState.isConnected; - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => _handleOfflineModeToggle(context, appState, offlineMode, isConnected), - borderRadius: BorderRadius.circular(10), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: offlineMode - ? Colors.orange.withValues(alpha: 0.15) - : colorScheme.onSurface.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: offlineMode - ? Colors.orange.withValues(alpha: 0.4) - : colorScheme.outline.withValues(alpha: 0.2), - ), - ), - child: Row( - children: [ - // Icon - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: offlineMode - ? Colors.orange.withValues(alpha: 0.2) - : colorScheme.onSurface.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - offlineMode ? Icons.cloud_off : Icons.cloud_queue, - size: 18, - color: offlineMode - ? (isDark ? Colors.orange.shade400 : Colors.orange.shade700) - : colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 12), - // Label and count - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Offline Mode', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: offlineMode - ? (isDark ? Colors.orange.shade300 : Colors.orange.shade800) - : colorScheme.onSurface, - ), + const SizedBox(width: 8), + // Hex text field + SizedBox( + width: 80, + child: TextField( + controller: _controller, + enabled: !isTargetedRunning, + maxLength: maxLen, + textCapitalization: TextCapitalization.characters, + style: TextStyle( + fontSize: 14, + fontFamily: 'monospace', + color: isTargetedRunning + ? colorScheme.onSurfaceVariant + : colorScheme.onSurface, + ), + decoration: InputDecoration( + hintText: + 'e.g. ${maxLen == 2 ? '4E' : maxLen == 4 ? '4E7A' : maxLen == 8 ? '4E7A3B00' : '4E7A3B'}', + hintStyle: TextStyle( + fontSize: 12, + color: + colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), - if (offlineMode && offlinePingCount > 0) - Text( - '$offlinePingCount pings saved locally', - style: TextStyle( - fontSize: 11, - color: isDark ? Colors.orange.shade400 : Colors.orange.shade600, - ), - ) - else - Text( - offlineMode - ? 'Data saved locally' - : 'Uploads immediately', - style: TextStyle( - fontSize: 11, - color: colorScheme.onSurfaceVariant, - ), - ), + counterText: '', + isDense: true, + contentPadding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9a-fA-F]')), + _UpperCaseTextFormatter(), ], + onChanged: (value) { + appState.setTargetRepeaterId(value.trim().toUpperCase()); + setState(() {}); + }, ), ), - // Toggle indicator - Container( - width: 44, - height: 26, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(13), - color: offlineMode - ? (isDark ? Colors.orange.shade600 : Colors.orange.shade500) - : (isDark ? const Color(0xFF475569) : const Color(0xFFCBD5E1)), // slate-600/300 - ), - child: AnimatedAlign( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - alignment: offlineMode - ? Alignment.centerRight - : Alignment.centerLeft, - child: Container( - width: 22, - height: 22, - margin: const EdgeInsets.all(2), - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors.white, - ), + const SizedBox(width: 6), + // Choose repeater button + SizedBox( + width: 32, + height: 32, + child: IconButton( + icon: Icon( + Icons.list, + size: 18, + color: (!isTargetedRunning && appState.repeaters.isNotEmpty) + ? effectiveColor + : colorScheme.onSurfaceVariant.withValues(alpha: 0.3), ), + onPressed: + (!isTargetedRunning && appState.repeaters.isNotEmpty) + ? _showRepeaterPicker + : null, + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + tooltip: 'Choose repeater', ), ), ], ), - ), - ), + ); + }, + ); + } +} + +/// Text formatter that converts input to uppercase +class _UpperCaseTextFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + return TextEditingValue( + text: newValue.text.toUpperCase(), + selection: newValue.selection, ); } } @@ -820,7 +856,7 @@ class CompactPingControls extends StatefulWidget { } /// Tracks which button should stay expanded during cooldown -enum _LastActiveButton { none, sendPing, activeMode, passiveMode } +enum _LastActiveButton { none, sendPing, activeMode, passiveMode, targeted } class _CompactPingControlsState extends State { // Static so it persists across widget rebuilds (e.g., expand/minimize panel) @@ -828,213 +864,367 @@ class _CompactPingControlsState extends State { @override Widget build(BuildContext context) { - final appState = context.watch(); - final manualValidation = appState.manualPingValidation; // Manual ping validation (no distance check) - final autoValidation = appState.autoModeValidation; - final canPingManual = manualValidation == PingValidation.valid; // For Send Ping button - final canStartAuto = autoValidation == PingValidation.valid; - final isActiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.active; - final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; - final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; - final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; - final hybridEnabled = appState.preferences.hybridModeEnabled; - final isPendingDisable = appState.isPendingDisable; - final cooldownActive = appState.cooldownTimer.isRunning; - final cooldownRemaining = appState.cooldownTimer.remainingSec; - final manualCooldownActive = appState.manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) - final manualCooldownRemaining = appState.manualPingCooldownTimer.remainingSec; - final rxWindowActive = appState.rxWindowTimer.isRunning; - final rxWindowRemaining = appState.rxWindowTimer.remainingSec; - final isPingSending = appState.isPingSending; - final isPingInProgress = appState.isPingInProgress; - final autoPingWaiting = appState.autoPingTimer.isRunning; - final autoPingRemaining = appState.autoPingTimer.remainingSec; - final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; - final discoveryWindowRemaining = appState.discoveryWindowTimer.remainingSec; - - // TX is blocked when offline mode is active and connected - final txBlockedByOffline = appState.offlineMode && appState.isConnected; - - // TX not allowed when API says zone is at TX capacity - final txNotAllowed = appState.isConnected && !appState.txAllowed; - - final prefs = appState.preferences; - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; - - // Determine which button is currently active (not during cooldown) - final sendPingCurrentlyActive = (isPingSending || rxWindowActive || manualCooldownActive) && !isTxModeRunning; - final activeModeCurrentlyActive = isPendingDisable || isTxModeRunning; - final passiveModeCurrentlyActive = isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); - - // Track the last active button for cooldown - if (sendPingCurrentlyActive) { - _lastActiveButton = _LastActiveButton.sendPing; - } else if (activeModeCurrentlyActive) { - _lastActiveButton = _LastActiveButton.activeMode; - } else if (passiveModeCurrentlyActive) { - _lastActiveButton = _LastActiveButton.passiveMode; - } - // Reset when no cooldown and no activity - if (!cooldownActive && !manualCooldownActive && !sendPingCurrentlyActive && !activeModeCurrentlyActive && !passiveModeCurrentlyActive) { - _lastActiveButton = _LastActiveButton.none; - } + // Rebuild only when control-relevant state changes (not on GPS/noise/battery). + context.select(_controlsDepsOf); + final appState = context.read(); + return ListenableBuilder( + listenable: appState.timerListenable, + builder: (_, __) { + final manualValidation = appState + .manualPingValidation; // Manual ping validation (no distance check) + final autoValidation = appState.autoModeValidation; + final canPingManual = + manualValidation == PingValidation.valid; // For Send Ping button + final canStartAuto = autoValidation == PingValidation.valid; + final isActiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.active; + final isPassiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.passive; + final isHybridModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; + final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; + final isTargetedRunning = appState.isTargetedModeRunning; + final hybridEnabled = appState.preferences.hybridModeEnabled; + final isPendingDisable = appState.isPendingDisable; + final isAutoStarting = appState.isAutoPingStarting; + final cooldownActive = appState.cooldownTimer.isRunning; + final cooldownRemaining = appState.cooldownTimer.remainingSec; + final manualCooldownActive = appState.manualPingCooldownTimer + .isRunning; // Manual ping cooldown (15 seconds) + final manualCooldownRemaining = + appState.manualPingCooldownTimer.remainingSec; + final rxWindowActive = appState.rxWindowTimer.isRunning; + final rxWindowRemaining = appState.rxWindowTimer.remainingSec; + final isPingSending = appState.isPingSending; + final isPingInProgress = appState.isPingInProgress; + final autoPingWaiting = appState.autoPingTimer.isRunning; + final autoPingRemaining = appState.autoPingTimer.remainingSec; + final autoPingSkipped = appState.autoPingTimer.skipReason != null; + final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; + final discoveryWindowRemaining = + appState.discoveryWindowTimer.remainingSec; + + // TX is blocked when offline mode is active and connected + final txBlockedByOffline = appState.offlineMode && appState.isConnected; + + // TX not allowed when API says zone is at TX capacity + final txNotAllowed = appState.isConnected && !appState.txAllowed; + + final prefs = appState.preferences; + final isPowerSet = prefs.autoPowerSet || + prefs.powerLevelSet || + appState.deviceModel != null; + + // Determine which button is currently active (not during cooldown) + final sendPingCurrentlyActive = + (isPingSending || rxWindowActive || manualCooldownActive) && + !isTxModeRunning; + final activeModeCurrentlyActive = isPendingDisable || isTxModeRunning; + final passiveModeCurrentlyActive = + isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); + + // Track the last active button for cooldown + if (sendPingCurrentlyActive) { + _lastActiveButton = _LastActiveButton.sendPing; + } else if (activeModeCurrentlyActive) { + _lastActiveButton = _LastActiveButton.activeMode; + } else if (passiveModeCurrentlyActive) { + _lastActiveButton = _LastActiveButton.passiveMode; + } else if (isTargetedRunning) { + _lastActiveButton = _LastActiveButton.targeted; + } + // Reset when no cooldown and no activity + if (!cooldownActive && + !manualCooldownActive && + !sendPingCurrentlyActive && + !activeModeCurrentlyActive && + !passiveModeCurrentlyActive && + !isTargetedRunning) { + _lastActiveButton = _LastActiveButton.none; + } + + // Determine which button should be expanded + // During cooldown, the last active button stays expanded + final sendPingExpanded = sendPingCurrentlyActive || + (manualCooldownActive && + _lastActiveButton == _LastActiveButton.sendPing) || + (cooldownActive && _lastActiveButton == _LastActiveButton.sendPing); + final activeModeExpanded = activeModeCurrentlyActive || + (cooldownActive && + _lastActiveButton == _LastActiveButton.activeMode); + final passiveModeExpanded = passiveModeCurrentlyActive || + (cooldownActive && + _lastActiveButton == _LastActiveButton.passiveMode); + + // Determine which buttons are colored (enabled or active) + final sendPingEnabled = canPingManual && + // Grey Send Ping while an auto mode is starting so the single-ping icon + // doesn't show until the first auto ping fires (matches active/passive + // gating) (#389) + !isAutoStarting && + !isTxModeRunning && + !isTargetedRunning && + !cooldownActive && + !manualCooldownActive && + !txBlockedByOffline && + !txNotAllowed && + !rxWindowActive && + !isPingSending && + !discoveryWindowActive && + !isPendingDisable; + final sendPingActive = (isPingSending || rxWindowActive) && + !isTxModeRunning && + !cooldownActive && + !manualCooldownActive; + final sendPingShowColor = sendPingEnabled || sendPingActive; + + final activeModeEnabled = !isPendingDisable && + !isTargetedRunning && + !isAutoStarting && + ((isTxModeRunning || + (canStartAuto && + !isPassiveModeRunning && + !cooldownActive && + !isPingSending && + !rxWindowActive)) && + !txBlockedByOffline && + !txNotAllowed); + final activeModeActive = isPendingDisable || isTxModeRunning; + final activeModeShowColor = activeModeEnabled || activeModeActive; + + final passiveModeEnabled = isPassiveModeRunning || + (appState.isConnected && + !isTxModeRunning && + !isTargetedRunning && + !isPendingDisable && + !isAutoStarting && + !isPingSending && + !rxWindowActive && + !cooldownActive && + prefs.externalAntennaSet && + isPowerSet); + final passiveModeActive = + isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); + final passiveModeShowColor = passiveModeEnabled || passiveModeActive; + + // Trace Mode (only relevant when a repeater ID has been entered) + final hasTargetRepeaterId = appState.targetRepeaterId != null && + appState.targetRepeaterId!.isNotEmpty; + final targetedCurrentlyActive = isTargetedRunning; + final traceModeExpanded = targetedCurrentlyActive || + (cooldownActive && _lastActiveButton == _LastActiveButton.targeted); + final traceModeEnabled = hasTargetRepeaterId && + !isTxModeRunning && + !isPassiveModeRunning && + !isPendingDisable && + !isPingSending && + !rxWindowActive && + !cooldownActive && + !manualCooldownActive && + appState.isConnected && + prefs.externalAntennaSet && + isPowerSet; + final traceModeActive = isTargetedRunning; + final traceModeShowColor = traceModeEnabled || traceModeActive; + + // Check if any button is actively expanded (showing label) + final anyExpanded = sendPingExpanded || + activeModeExpanded || + passiveModeExpanded || + traceModeExpanded; + // Check if all buttons are disabled (no color) - used to split space equally in initial state + final allDisabled = !sendPingShowColor && + !activeModeShowColor && + !passiveModeShowColor && + (!hasTargetRepeaterId || !traceModeShowColor); + + // Build the buttons + final sendPingButton = _CompactActionButton( + icon: Icons.cell_tower, + label: _getSendPingLabel( + isPingSending: isPingSending, + rxWindowActive: rxWindowActive, + rxWindowRemaining: rxWindowRemaining, + manualCooldownActive: manualCooldownActive, + manualCooldownRemaining: manualCooldownRemaining, + discoveryWindowActive: discoveryWindowActive, + discoveryWindowRemaining: discoveryWindowRemaining, + cooldownActive: cooldownActive, + cooldownRemaining: cooldownRemaining, + showFullText: sendPingExpanded, + ), + color: const Color(0xFF0EA5E9), // sky-500 + enabled: sendPingEnabled, + isActive: sendPingActive, + isExpanded: sendPingExpanded, + progress: rxWindowActive && !isTxModeRunning + ? appState.rxWindowTimer.progress + : manualCooldownActive && + _lastActiveButton == _LastActiveButton.sendPing + ? appState.manualPingCooldownTimer.progress + : cooldownActive && + _lastActiveButton == _LastActiveButton.sendPing + ? appState.cooldownTimer.progress + : null, + onPressed: () => _sendPing(context, appState), + ); - // Determine which button should be expanded - // During cooldown, the last active button stays expanded - final sendPingExpanded = sendPingCurrentlyActive || - (manualCooldownActive && _lastActiveButton == _LastActiveButton.sendPing) || - (cooldownActive && _lastActiveButton == _LastActiveButton.sendPing); - final activeModeExpanded = activeModeCurrentlyActive || - (cooldownActive && _lastActiveButton == _LastActiveButton.activeMode); - final passiveModeExpanded = passiveModeCurrentlyActive || - (cooldownActive && _lastActiveButton == _LastActiveButton.passiveMode); - - // Determine which buttons are colored (enabled or active) - final sendPingEnabled = canPingManual && !isTxModeRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && - !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable; - final sendPingActive = (isPingSending || rxWindowActive) && !isTxModeRunning && !cooldownActive && !manualCooldownActive; - final sendPingShowColor = sendPingEnabled || sendPingActive; - - final activeModeEnabled = !isPendingDisable && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed); - final activeModeActive = isPendingDisable || isTxModeRunning; - final activeModeShowColor = activeModeEnabled || activeModeActive; - - final passiveModeEnabled = isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isPendingDisable && - !isPingSending && !rxWindowActive && !cooldownActive && - prefs.externalAntennaSet && isPowerSet); - final passiveModeActive = isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting); - final passiveModeShowColor = passiveModeEnabled || passiveModeActive; - - // Check if any button is actively expanded (showing label) - final anyExpanded = sendPingExpanded || activeModeExpanded || passiveModeExpanded; - // Check if all buttons are disabled (no color) - used to split space equally in initial state - final allDisabled = !sendPingShowColor && !activeModeShowColor && !passiveModeShowColor; - - // Build the buttons - final sendPingButton = _CompactActionButton( - icon: Icons.cell_tower, - label: _getSendPingLabel( - isPingSending: isPingSending, - rxWindowActive: rxWindowActive, - rxWindowRemaining: rxWindowRemaining, - manualCooldownActive: manualCooldownActive, - manualCooldownRemaining: manualCooldownRemaining, - discoveryWindowActive: discoveryWindowActive, - discoveryWindowRemaining: discoveryWindowRemaining, - cooldownActive: cooldownActive, - cooldownRemaining: cooldownRemaining, - showFullText: sendPingExpanded, - ), - color: const Color(0xFF0EA5E9), // sky-500 - enabled: sendPingEnabled, - isActive: sendPingActive, - isExpanded: sendPingExpanded, - progress: rxWindowActive && !isTxModeRunning - ? appState.rxWindowTimer.progress - : manualCooldownActive && _lastActiveButton == _LastActiveButton.sendPing - ? appState.manualPingCooldownTimer.progress - : cooldownActive && _lastActiveButton == _LastActiveButton.sendPing - ? appState.cooldownTimer.progress - : null, - onPressed: () => _sendPing(context, appState), - ); + final activeModeButton = _CompactActionButton( + icon: hybridEnabled ? Icons.compare_arrows : Icons.sensors, + label: _getActiveModeLabel( + isActiveModeRunning: isTxModeRunning, + isPingInProgress: isPingInProgress, + rxWindowActive: rxWindowActive, + rxWindowRemaining: rxWindowRemaining, + autoPingWaiting: autoPingWaiting, + autoPingRemaining: autoPingRemaining, + isPendingDisable: isPendingDisable, + showFullText: activeModeExpanded, + cooldownActive: cooldownActive, + cooldownRemaining: cooldownRemaining, + isExpandedDuringCooldown: activeModeExpanded && cooldownActive, + isSkipped: autoPingSkipped, + discoveryWindowActive: discoveryWindowActive, + discoveryWindowRemaining: discoveryWindowRemaining, + ), + color: isPendingDisable + ? Colors.orange + : isTxModeRunning + ? const Color(0xFF22C55E) // green-500 + : const Color(0xFF6366F1), // indigo-500 + enabled: activeModeEnabled, + isActive: activeModeActive, + isExpanded: activeModeExpanded, + progress: (rxWindowActive || discoveryWindowActive) && isTxModeRunning + ? (discoveryWindowActive + ? appState.discoveryWindowTimer.progress + : appState.rxWindowTimer.progress) + : autoPingWaiting && isTxModeRunning + ? appState.autoPingTimer.progress + : cooldownActive && + _lastActiveButton == _LastActiveButton.activeMode + ? appState.cooldownTimer.progress + : null, + onPressed: () => hybridEnabled + ? _toggleHybridAuto(context, appState) + : _toggleTxRxAuto(context, appState), + ); - final activeModeButton = _CompactActionButton( - icon: hybridEnabled ? Icons.compare_arrows : Icons.sensors, - label: _getActiveModeLabel( - isActiveModeRunning: isTxModeRunning, - isPingInProgress: isPingInProgress, - rxWindowActive: rxWindowActive, - rxWindowRemaining: rxWindowRemaining, - autoPingWaiting: autoPingWaiting, - autoPingRemaining: autoPingRemaining, - isPendingDisable: isPendingDisable, - showFullText: activeModeExpanded, - cooldownActive: cooldownActive, - cooldownRemaining: cooldownRemaining, - isExpandedDuringCooldown: activeModeExpanded && cooldownActive, - discoveryWindowActive: discoveryWindowActive, - discoveryWindowRemaining: discoveryWindowRemaining, - ), - color: isPendingDisable - ? Colors.orange - : isTxModeRunning + final passiveModeButton = _CompactActionButton( + icon: Icons.hearing, + label: _getPassiveModeLabel( + isPassiveModeRunning: isPassiveModeRunning, + discoveryWindowActive: discoveryWindowActive, + discoveryWindowRemaining: discoveryWindowRemaining, + autoPingWaiting: autoPingWaiting, + autoPingRemaining: autoPingRemaining, + showFullText: passiveModeExpanded, + cooldownActive: cooldownActive, + cooldownRemaining: cooldownRemaining, + isExpandedDuringCooldown: passiveModeExpanded && cooldownActive, + isSkipped: autoPingSkipped, + ), + color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: activeModeEnabled, - isActive: activeModeActive, - isExpanded: activeModeExpanded, - progress: (rxWindowActive || discoveryWindowActive) && isTxModeRunning - ? (discoveryWindowActive ? appState.discoveryWindowTimer.progress : appState.rxWindowTimer.progress) - : autoPingWaiting && isTxModeRunning - ? appState.autoPingTimer.progress - : cooldownActive && _lastActiveButton == _LastActiveButton.activeMode - ? appState.cooldownTimer.progress - : null, - onPressed: () => hybridEnabled ? _toggleHybridAuto(context, appState) : _toggleTxRxAuto(context, appState), - ); + enabled: passiveModeEnabled, + isActive: passiveModeActive, + isExpanded: passiveModeExpanded, + progress: discoveryWindowActive && isPassiveModeRunning + ? appState.discoveryWindowTimer.progress + : autoPingWaiting && isPassiveModeRunning + ? appState.autoPingTimer.progress + : cooldownActive && + _lastActiveButton == _LastActiveButton.passiveMode + ? appState.cooldownTimer.progress + : null, + onPressed: () => _toggleRxAuto(context, appState), + ); - final passiveModeButton = _CompactActionButton( - icon: Icons.hearing, - label: _getPassiveModeLabel( - isPassiveModeRunning: isPassiveModeRunning, - discoveryWindowActive: discoveryWindowActive, - discoveryWindowRemaining: discoveryWindowRemaining, - autoPingWaiting: autoPingWaiting, - autoPingRemaining: autoPingRemaining, - showFullText: passiveModeExpanded, - cooldownActive: cooldownActive, - cooldownRemaining: cooldownRemaining, - isExpandedDuringCooldown: passiveModeExpanded && cooldownActive, - ), - color: isPassiveModeRunning - ? const Color(0xFF22C55E) // green-500 - : const Color(0xFF6366F1), // indigo-500 - enabled: passiveModeEnabled, - isActive: passiveModeActive, - isExpanded: passiveModeExpanded, - progress: discoveryWindowActive && isPassiveModeRunning - ? appState.discoveryWindowTimer.progress - : autoPingWaiting && isPassiveModeRunning - ? appState.autoPingTimer.progress - : cooldownActive && _lastActiveButton == _LastActiveButton.passiveMode - ? appState.cooldownTimer.progress - : null, - onPressed: () => _toggleRxAuto(context, appState), - ); + // Build trace mode button (only used when hasTargetRepeaterId) + final traceModeButton = _CompactActionButton( + icon: Icons.route, + label: _getTraceModeLabel( + isTargetedRunning: isTargetedRunning, + discoveryWindowActive: discoveryWindowActive, + discoveryWindowRemaining: discoveryWindowRemaining, + autoPingWaiting: autoPingWaiting, + autoPingRemaining: autoPingRemaining, + showFullText: traceModeExpanded, + cooldownActive: cooldownActive, + cooldownRemaining: cooldownRemaining, + isExpandedDuringCooldown: traceModeExpanded && cooldownActive, + isSkipped: autoPingSkipped, + ), + color: isTargetedRunning + ? const Color(0xFF22C55E) // green-500 + : const Color(0xFF06B6D4), // cyan-500 + enabled: traceModeEnabled || isTargetedRunning, + isActive: traceModeActive, + isExpanded: traceModeExpanded, + progress: discoveryWindowActive && isTargetedRunning + ? appState.discoveryWindowTimer.progress + : autoPingWaiting && isTargetedRunning + ? appState.autoPingTimer.progress + : cooldownActive && + _lastActiveButton == _LastActiveButton.targeted + ? appState.cooldownTimer.progress + : null, + onPressed: () { + HapticFeedback.lightImpact(); + appState.toggleAutoPing(AutoMode.targeted); + }, + ); - // Layout logic: - // - If button is expanded (including during cooldown): stays big - // - If no button is expanded: all colored buttons share space equally - // - Grey non-expanded buttons are icon-only - return Row( - children: [ - // Send Ping - expanded buttons stay big even when grey (cooldown) - if (sendPingExpanded) - Expanded(child: sendPingButton) - else if (!anyExpanded && (sendPingShowColor || allDisabled)) - Expanded(child: sendPingButton) - else - sendPingButton, - const SizedBox(width: 6), - - // Active Mode - if (activeModeExpanded) - Expanded(child: activeModeButton) - else if (!anyExpanded && (activeModeShowColor || allDisabled)) - Expanded(child: activeModeButton) - else - activeModeButton, - const SizedBox(width: 6), - - // Passive Mode - if (passiveModeExpanded) - Expanded(child: passiveModeButton) - else if (!anyExpanded && (passiveModeShowColor || allDisabled)) - Expanded(child: passiveModeButton) - else - passiveModeButton, - ], + final floodTrafficVisible = appState.floodTrafficEnabled; + + // Layout logic: + // - If button is expanded (including during cooldown): stays big + // - If no button is expanded: all colored buttons share space equally + // - Grey non-expanded buttons are icon-only + return Row( + children: [ + if (!txNotAllowed && floodTrafficVisible) ...[ + // Send Ping - expanded buttons stay big even when grey (cooldown) + if (sendPingExpanded) + Expanded(child: sendPingButton) + else if (!anyExpanded && (sendPingShowColor || allDisabled)) + Expanded(child: sendPingButton) + else + sendPingButton, + const SizedBox(width: 6), + + // Active Mode + if (activeModeExpanded) + Expanded(child: activeModeButton) + else if (!anyExpanded && (activeModeShowColor || allDisabled)) + Expanded(child: activeModeButton) + else + activeModeButton, + const SizedBox(width: 6), + ], + + // Passive Mode + if (passiveModeExpanded) + Expanded(child: passiveModeButton) + else if (!anyExpanded && (passiveModeShowColor || allDisabled)) + Expanded(child: passiveModeButton) + else + passiveModeButton, + + // Trace Mode (only shown when a repeater ID has been entered) + if (hasTargetRepeaterId) ...[ + const SizedBox(width: 6), + if (traceModeExpanded) + Expanded(child: traceModeButton) + else if (!anyExpanded && (traceModeShowColor || allDisabled)) + Expanded(child: traceModeButton) + else + traceModeButton, + ], + ], + ); + }, ); } @@ -1053,10 +1243,26 @@ class _CompactPingControlsState extends State { required bool showFullText, }) { if (isPingSending) return showFullText ? 'Sending...' : '...'; - if (rxWindowActive) return showFullText ? 'Listening ${rxWindowRemaining}s' : '${rxWindowRemaining}s'; - if (manualCooldownActive) return showFullText ? 'Cooldown ${manualCooldownRemaining}s' : '${manualCooldownRemaining}s'; - if (discoveryWindowActive) return showFullText ? 'Cooldown ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (cooldownActive) return showFullText ? 'Cooldown ${cooldownRemaining}s' : '${cooldownRemaining}s'; + if (rxWindowActive) { + return showFullText + ? 'Listening ${rxWindowRemaining}s' + : '${rxWindowRemaining}s'; + } + if (manualCooldownActive) { + return showFullText + ? 'Cooldown ${manualCooldownRemaining}s' + : '${manualCooldownRemaining}s'; + } + if (discoveryWindowActive) { + return showFullText + ? 'Cooldown ${discoveryWindowRemaining}s' + : '${discoveryWindowRemaining}s'; + } + if (cooldownActive) { + return showFullText + ? 'Cooldown ${cooldownRemaining}s' + : '${cooldownRemaining}s'; + } return null; } @@ -1074,23 +1280,50 @@ class _CompactPingControlsState extends State { required bool cooldownActive, required int cooldownRemaining, required bool isExpandedDuringCooldown, + required bool isSkipped, bool discoveryWindowActive = false, int discoveryWindowRemaining = 0, }) { if (isPendingDisable) { - if (rxWindowActive) return showFullText ? 'Stopping ${rxWindowRemaining}s' : '${rxWindowRemaining}s'; - if (discoveryWindowActive) return showFullText ? 'Stopping ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; + if (rxWindowActive) { + return showFullText + ? 'Stopping ${rxWindowRemaining}s' + : '${rxWindowRemaining}s'; + } + if (discoveryWindowActive) { + return showFullText + ? 'Stopping ${discoveryWindowRemaining}s' + : '${discoveryWindowRemaining}s'; + } return showFullText ? 'Stopping...' : '...'; } if (isActiveModeRunning) { - if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (isPingInProgress && !rxWindowActive) return showFullText ? 'Sending...' : '...'; - if (rxWindowActive) return showFullText ? 'Listening ${rxWindowRemaining}s' : '${rxWindowRemaining}s'; - if (autoPingWaiting) return showFullText ? 'Waiting ${autoPingRemaining}s' : '${autoPingRemaining}s'; + if (discoveryWindowActive) { + return showFullText + ? 'Listening ${discoveryWindowRemaining}s' + : '${discoveryWindowRemaining}s'; + } + if (isPingInProgress && !rxWindowActive) { + return showFullText ? 'Sending...' : '...'; + } + if (rxWindowActive) { + return showFullText + ? 'Listening ${rxWindowRemaining}s' + : '${rxWindowRemaining}s'; + } + if (autoPingWaiting) { + return showFullText + ? (isSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Waiting ${autoPingRemaining}s') + : '${autoPingRemaining}s'; + } } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { - return showFullText ? 'Cooldown ${cooldownRemaining}s' : '${cooldownRemaining}s'; + return showFullText + ? 'Cooldown ${cooldownRemaining}s' + : '${cooldownRemaining}s'; } return null; } @@ -1107,19 +1340,71 @@ class _CompactPingControlsState extends State { required bool cooldownActive, required int cooldownRemaining, required bool isExpandedDuringCooldown, + required bool isSkipped, }) { if (isPassiveModeRunning) { - if (discoveryWindowActive) return showFullText ? 'Listening ${discoveryWindowRemaining}s' : '${discoveryWindowRemaining}s'; - if (autoPingWaiting) return showFullText ? 'Waiting ${autoPingRemaining}s' : '${autoPingRemaining}s'; + if (discoveryWindowActive) { + return showFullText + ? 'Listening ${discoveryWindowRemaining}s' + : '${discoveryWindowRemaining}s'; + } + if (autoPingWaiting) { + return showFullText + ? (isSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Waiting ${autoPingRemaining}s') + : '${autoPingRemaining}s'; + } + } + // Show cooldown if this button caused it + if (cooldownActive && isExpandedDuringCooldown) { + return showFullText + ? 'Cooldown ${cooldownRemaining}s' + : '${cooldownRemaining}s'; + } + return null; + } + + /// Get label for Trace Mode button + /// When showFullText is true: "Listening 5s", when false: "5s" + String? _getTraceModeLabel({ + required bool isTargetedRunning, + required bool discoveryWindowActive, + required int discoveryWindowRemaining, + required bool autoPingWaiting, + required int autoPingRemaining, + required bool showFullText, + required bool cooldownActive, + required int cooldownRemaining, + required bool isExpandedDuringCooldown, + required bool isSkipped, + }) { + if (isTargetedRunning) { + if (discoveryWindowActive) { + return showFullText + ? 'Listening ${discoveryWindowRemaining}s' + : '${discoveryWindowRemaining}s'; + } + if (autoPingWaiting) { + return showFullText + ? (isSkipped + ? 'Skipped ${autoPingRemaining}s' + : 'Next in ${autoPingRemaining}s') + : '${autoPingRemaining}s'; + } + return showFullText ? 'Stop' : null; } // Show cooldown if this button caused it if (cooldownActive && isExpandedDuringCooldown) { - return showFullText ? 'Cooldown ${cooldownRemaining}s' : '${cooldownRemaining}s'; + return showFullText + ? 'Cooldown ${cooldownRemaining}s' + : '${cooldownRemaining}s'; } return null; } - Future _sendPing(BuildContext context, AppStateProvider appState) async { + Future _sendPing( + BuildContext context, AppStateProvider appState) async { HapticFeedback.mediumImpact(); final success = await appState.sendPing(); @@ -1129,17 +1414,20 @@ class _CompactPingControlsState extends State { } } - Future _toggleTxRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleTxRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.active); } - Future _toggleHybridAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleHybridAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.hybrid); } - Future _toggleRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.passive); } @@ -1154,187 +1442,233 @@ class LandscapePingControls extends StatelessWidget { @override Widget build(BuildContext context) { - final appState = context.watch(); - final manualValidation = appState.manualPingValidation; // Manual ping validation (no distance check) - final autoValidation = appState.autoModeValidation; - final canPingManual = manualValidation == PingValidation.valid; // For Send Ping button - final canStartAuto = autoValidation == PingValidation.valid; - final isActiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.active; - final isPassiveModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.passive; - final isHybridModeRunning = appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; - final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; - final hybridEnabled = appState.preferences.hybridModeEnabled; - final isPendingDisable = appState.isPendingDisable; - final cooldownActive = appState.cooldownTimer.isRunning; - final cooldownRemaining = appState.cooldownTimer.remainingSec; - final manualCooldownActive = appState.manualPingCooldownTimer.isRunning; // Manual ping cooldown (15 seconds) - final manualCooldownRemaining = appState.manualPingCooldownTimer.remainingSec; - final rxWindowActive = appState.rxWindowTimer.isRunning; - final rxWindowRemaining = appState.rxWindowTimer.remainingSec; - final isPingSending = appState.isPingSending; - final autoPingWaiting = appState.autoPingTimer.isRunning; - final autoPingRemaining = appState.autoPingTimer.remainingSec; - final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; - final discoveryWindowRemaining = appState.discoveryWindowTimer.remainingSec; - - // TX is blocked when offline mode is active and connected - final txBlockedByOffline = appState.offlineMode && appState.isConnected; - - // TX not allowed when API says zone is at TX capacity - final txNotAllowed = appState.isConnected && !appState.txAllowed; - - final prefs = appState.preferences; - final isPowerSet = prefs.autoPowerSet || prefs.powerLevelSet || appState.deviceModel != null; - - // Antenna selector - final soundEnabled = appState.isSoundEnabled; - final offlineMode = appState.offlineMode; - final isConnected = appState.isConnected; - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Antenna selector (compact) - _LandscapeAntennaSelector( - externalAntenna: prefs.externalAntenna, - externalAntennaSet: prefs.externalAntennaSet, - onChanged: (value) => appState.updatePreferences( - prefs.copyWith(externalAntenna: value, externalAntennaSet: true), - ), - ), - const SizedBox(height: 10), - - // Action buttons row (icon-only) - Row( + // Rebuild only when control-relevant state changes (not on GPS/noise/battery). + context.select(_controlsDepsOf); + final appState = context.read(); + return ListenableBuilder( + listenable: appState.timerListenable, + builder: (_, __) { + final manualValidation = appState + .manualPingValidation; // Manual ping validation (no distance check) + final autoValidation = appState.autoModeValidation; + final canPingManual = + manualValidation == PingValidation.valid; // For Send Ping button + final canStartAuto = autoValidation == PingValidation.valid; + final isActiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.active; + final isPassiveModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.passive; + final isHybridModeRunning = + appState.autoPingEnabled && appState.autoMode == AutoMode.hybrid; + final isTxModeRunning = isActiveModeRunning || isHybridModeRunning; + final isTargetedRunning = appState.isTargetedModeRunning; + final hybridEnabled = appState.preferences.hybridModeEnabled; + final isPendingDisable = appState.isPendingDisable; + final isAutoStarting = appState.isAutoPingStarting; + final cooldownActive = appState.cooldownTimer.isRunning; + final cooldownRemaining = appState.cooldownTimer.remainingSec; + final manualCooldownActive = appState.manualPingCooldownTimer + .isRunning; // Manual ping cooldown (15 seconds) + final manualCooldownRemaining = + appState.manualPingCooldownTimer.remainingSec; + final rxWindowActive = appState.rxWindowTimer.isRunning; + final rxWindowRemaining = appState.rxWindowTimer.remainingSec; + final isPingSending = appState.isPingSending; + final autoPingWaiting = appState.autoPingTimer.isRunning; + final autoPingRemaining = appState.autoPingTimer.remainingSec; + final discoveryWindowActive = appState.discoveryWindowTimer.isRunning; + final discoveryWindowRemaining = + appState.discoveryWindowTimer.remainingSec; + + // TX is blocked when offline mode is active and connected + final txBlockedByOffline = appState.offlineMode && appState.isConnected; + + // TX not allowed when API says zone is at TX capacity + final txNotAllowed = appState.isConnected && !appState.txAllowed; + + final prefs = appState.preferences; + final isPowerSet = prefs.autoPowerSet || + prefs.powerLevelSet || + appState.deviceModel != null; + + final floodTrafficVisible = appState.floodTrafficEnabled; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // TX Ping button - Expanded( - child: _LandscapeIconButton( - icon: Icons.cell_tower, - tooltip: txNotAllowed ? 'Zone Full (Passive Only)' : 'Send Ping', - color: const Color(0xFF0EA5E9), // sky-500 - enabled: canPingManual && !isTxModeRunning && !cooldownActive && !manualCooldownActive && !txBlockedByOffline && !txNotAllowed && - !rxWindowActive && !isPingSending && !discoveryWindowActive && !isPendingDisable, - isActive: (isPingSending || rxWindowActive) && !isTxModeRunning, - countdown: isPingSending - ? null - : rxWindowActive && !isTxModeRunning - ? rxWindowRemaining - : manualCooldownActive - ? manualCooldownRemaining - : discoveryWindowActive - ? discoveryWindowRemaining - : cooldownActive - ? cooldownRemaining - : null, - onPressed: () => _sendPing(context, appState), + // Antenna selector (compact) + _LandscapeAntennaSelector( + externalAntenna: prefs.externalAntenna, + externalAntennaSet: prefs.externalAntennaSet, + onChanged: (value) => appState.updatePreferences( + prefs.copyWith( + externalAntenna: value, externalAntennaSet: true), ), ), - const SizedBox(width: 8), - - // Active/Hybrid Mode button - Expanded( - child: _LandscapeIconButton( - icon: hybridEnabled ? Icons.compare_arrows : Icons.sensors, - tooltip: txNotAllowed ? 'Zone Full (Passive Only)' : (hybridEnabled ? 'Hybrid Mode' : 'Active Mode'), - color: isPendingDisable - ? Colors.orange - : isTxModeRunning + const SizedBox(height: 10), + + // Action buttons row (icon-only) + Row( + children: [ + if (!txNotAllowed && floodTrafficVisible) ...[ + // TX Ping button + Expanded( + child: _LandscapeIconButton( + icon: Icons.cell_tower, + tooltip: txNotAllowed + ? 'Zone Full (Passive Only)' + : 'Send Ping', + color: const Color(0xFF0EA5E9), // sky-500 + enabled: canPingManual && + // Grey Send Ping while an auto mode is starting so the + // single-ping icon doesn't show until the first auto ping + // fires (matches active/passive gating) (#389) + !isAutoStarting && + !isTxModeRunning && + !isTargetedRunning && + !cooldownActive && + !manualCooldownActive && + !txBlockedByOffline && + !txNotAllowed && + !rxWindowActive && + !isPingSending && + !discoveryWindowActive && + !isPendingDisable, + isActive: + (isPingSending || rxWindowActive) && !isTxModeRunning, + countdown: isPingSending + ? null + : rxWindowActive && !isTxModeRunning + ? rxWindowRemaining + : manualCooldownActive + ? manualCooldownRemaining + : discoveryWindowActive + ? discoveryWindowRemaining + : cooldownActive + ? cooldownRemaining + : null, + onPressed: () => _sendPing(context, appState), + ), + ), + const SizedBox(width: 8), + + // Active/Hybrid Mode button + Expanded( + child: _LandscapeIconButton( + icon: + hybridEnabled ? Icons.compare_arrows : Icons.sensors, + tooltip: txNotAllowed + ? 'Zone Full (Passive Only)' + : (hybridEnabled ? 'Hybrid Mode' : 'Active Mode'), + color: isPendingDisable + ? Colors.orange + : isTxModeRunning + ? const Color(0xFF22C55E) // green-500 + : const Color(0xFF6366F1), // indigo-500 + enabled: !isPendingDisable && + !isTargetedRunning && + !isAutoStarting && + ((isTxModeRunning || + (canStartAuto && + !isPassiveModeRunning && + !cooldownActive && + !isPingSending && + !rxWindowActive)) && + !txBlockedByOffline && + !txNotAllowed), + isActive: isPendingDisable || isTxModeRunning, + countdown: isTxModeRunning + ? (discoveryWindowActive + ? discoveryWindowRemaining + : rxWindowActive + ? rxWindowRemaining + : autoPingWaiting + ? autoPingRemaining + : null) + : isPendingDisable && + (rxWindowActive || discoveryWindowActive) + ? (rxWindowActive + ? rxWindowRemaining + : discoveryWindowRemaining) + : null, + onPressed: () => hybridEnabled + ? _toggleHybridAuto(context, appState) + : _toggleTxRxAuto(context, appState), + ), + ), + const SizedBox(width: 8), + ], + + // Passive Mode button + Expanded( + child: _LandscapeIconButton( + icon: Icons.hearing, + tooltip: 'Passive Mode', + color: isPassiveModeRunning ? const Color(0xFF22C55E) // green-500 : const Color(0xFF6366F1), // indigo-500 - enabled: !isPendingDisable && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && !cooldownActive && !isPingSending && !rxWindowActive)) && !txBlockedByOffline && !txNotAllowed), - isActive: isPendingDisable || isTxModeRunning, - countdown: isTxModeRunning - ? (discoveryWindowActive - ? discoveryWindowRemaining - : rxWindowActive - ? rxWindowRemaining + enabled: isPassiveModeRunning || + (appState.isConnected && + !isTxModeRunning && + !isTargetedRunning && + !isPendingDisable && + !isAutoStarting && + !isPingSending && + !rxWindowActive && + !cooldownActive && + prefs.externalAntennaSet && + isPowerSet), + isActive: isPassiveModeRunning && + (discoveryWindowActive || autoPingWaiting), + countdown: isPassiveModeRunning + ? (discoveryWindowActive + ? discoveryWindowRemaining : autoPingWaiting ? autoPingRemaining : null) - : isPendingDisable && (rxWindowActive || discoveryWindowActive) - ? (rxWindowActive ? rxWindowRemaining : discoveryWindowRemaining) : null, - onPressed: () => hybridEnabled ? _toggleHybridAuto(context, appState) : _toggleTxRxAuto(context, appState), - ), - ), - const SizedBox(width: 8), - - // Passive Mode button - Expanded( - child: _LandscapeIconButton( - icon: Icons.hearing, - tooltip: 'Passive Mode', - color: isPassiveModeRunning - ? const Color(0xFF22C55E) // green-500 - : const Color(0xFF6366F1), // indigo-500 - enabled: isPassiveModeRunning || (appState.isConnected && !isTxModeRunning && !isPendingDisable && - !isPingSending && !rxWindowActive && !cooldownActive && - prefs.externalAntennaSet && isPowerSet), - isActive: isPassiveModeRunning && (discoveryWindowActive || autoPingWaiting), - countdown: isPassiveModeRunning - ? (discoveryWindowActive - ? discoveryWindowRemaining - : autoPingWaiting - ? autoPingRemaining - : null) - : null, - onPressed: () => _toggleRxAuto(context, appState), - ), - ), - ], - ), - const SizedBox(height: 10), - - // Compact row for toggles - Row( - children: [ - Expanded( - child: _LandscapeToggle( - icon: offlineMode ? Icons.cloud_off : Icons.cloud_queue, - label: 'Offline', - isOn: offlineMode, - color: Colors.orange, - onTap: () => _OfflineModeToggle._handleOfflineModeToggle( - context, - appState, - offlineMode, - isConnected, + onPressed: () => _toggleRxAuto(context, appState), + ), ), - ), + ], ), - const SizedBox(width: 8), - Expanded( - child: _LandscapeToggle( - icon: soundEnabled ? Icons.volume_up : Icons.volume_off, - label: 'Sound', - isOn: soundEnabled, - color: Colors.blue, - onTap: () => appState.toggleSoundEnabled(), - ), + const SizedBox(height: 10), + + // Targeted Ping controls (Trace Mode) + _TargetedPingSection( + isAnyModeRunning: isActiveModeRunning || + isPassiveModeRunning || + isHybridModeRunning, + cooldownActive: cooldownActive, + cooldownRemaining: cooldownRemaining, + compact: true, ), ], - ), - ], + ); + }, ); } - Future _sendPing(BuildContext context, AppStateProvider appState) async { + Future _sendPing( + BuildContext context, AppStateProvider appState) async { HapticFeedback.mediumImpact(); await appState.sendPing(); } - Future _toggleTxRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleTxRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.active); } - Future _toggleHybridAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleHybridAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.hybrid); } - Future _toggleRxAuto(BuildContext context, AppStateProvider appState) async { + Future _toggleRxAuto( + BuildContext context, AppStateProvider appState) async { HapticFeedback.lightImpact(); await appState.toggleAutoPing(AutoMode.passive); } @@ -1377,20 +1711,26 @@ class _LandscapeAntennaSelector extends StatelessWidget { style: TextStyle( fontSize: 10, fontWeight: FontWeight.w500, - color: externalAntennaSet ? colorScheme.onSurfaceVariant : notSetColor, + color: externalAntennaSet + ? colorScheme.onSurfaceVariant + : notSetColor, ), ), if (!externalAntennaSet) ...[ const SizedBox(width: 4), Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + padding: + const EdgeInsets.symmetric(horizontal: 4, vertical: 1), decoration: BoxDecoration( color: notSetColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(4), ), child: const Text( 'Required', - style: TextStyle(fontSize: 8, fontWeight: FontWeight.w600, color: notSetColor), + style: TextStyle( + fontSize: 8, + fontWeight: FontWeight.w600, + color: notSetColor), ), ), ], @@ -1403,7 +1743,8 @@ class _LandscapeAntennaSelector extends StatelessWidget { decoration: BoxDecoration( color: colorScheme.onSurface.withValues(alpha: 0.06), borderRadius: BorderRadius.circular(8), - border: Border.all(color: colorScheme.outline.withValues(alpha: 0.2)), + border: + Border.all(color: colorScheme.outline.withValues(alpha: 0.2)), ), child: Row( children: [ @@ -1417,22 +1758,30 @@ class _LandscapeAntennaSelector extends StatelessWidget { color: (!externalAntenna && externalAntennaSet) ? Colors.orange.withValues(alpha: 0.25) : Colors.transparent, - borderRadius: const BorderRadius.horizontal(left: Radius.circular(7)), + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(7)), ), alignment: Alignment.center, child: Text( 'Internal', style: TextStyle( fontSize: 11, - fontWeight: (!externalAntenna && externalAntennaSet) ? FontWeight.w600 : FontWeight.w500, - color: (!externalAntenna && externalAntennaSet) ? Colors.orange : colorScheme.onSurfaceVariant, + fontWeight: (!externalAntenna && externalAntennaSet) + ? FontWeight.w600 + : FontWeight.w500, + color: (!externalAntenna && externalAntennaSet) + ? Colors.orange + : colorScheme.onSurfaceVariant, ), ), ), ), ), // Divider - Container(width: 1, height: 18, color: colorScheme.outline.withValues(alpha: 0.3)), + Container( + width: 1, + height: 18, + color: colorScheme.outline.withValues(alpha: 0.3)), // External option Expanded( child: GestureDetector( @@ -1443,15 +1792,20 @@ class _LandscapeAntennaSelector extends StatelessWidget { color: (externalAntenna && externalAntennaSet) ? Colors.orange.withValues(alpha: 0.25) : Colors.transparent, - borderRadius: const BorderRadius.horizontal(right: Radius.circular(7)), + borderRadius: const BorderRadius.horizontal( + right: Radius.circular(7)), ), alignment: Alignment.center, child: Text( 'External', style: TextStyle( fontSize: 11, - fontWeight: (externalAntenna && externalAntennaSet) ? FontWeight.w600 : FontWeight.w500, - color: (externalAntenna && externalAntennaSet) ? Colors.orange : colorScheme.onSurfaceVariant, + fontWeight: (externalAntenna && externalAntennaSet) + ? FontWeight.w600 + : FontWeight.w500, + color: (externalAntenna && externalAntennaSet) + ? Colors.orange + : colorScheme.onSurfaceVariant, ), ), ), @@ -1489,193 +1843,98 @@ class _LandscapeIconButton extends StatefulWidget { State<_LandscapeIconButton> createState() => _LandscapeIconButtonState(); } -class _LandscapeIconButtonState extends State<_LandscapeIconButton> - with SingleTickerProviderStateMixin { - late AnimationController _pulseController; - late Animation _pulseAnimation; - - @override - void initState() { - super.initState(); - _pulseController = AnimationController( - duration: const Duration(milliseconds: 1200), - vsync: this, - ); - _pulseAnimation = Tween(begin: 0.15, end: 0.35).animate( - CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), - ); - - if (widget.isActive) { - _pulseController.repeat(reverse: true); - } - } - - @override - void didUpdateWidget(_LandscapeIconButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.isActive && !oldWidget.isActive) { - _pulseController.repeat(reverse: true); - } else if (!widget.isActive && oldWidget.isActive) { - _pulseController.stop(); - _pulseController.reset(); - } - } - - @override - void dispose() { - _pulseController.dispose(); - super.dispose(); - } - +class _LandscapeIconButtonState extends State<_LandscapeIconButton> { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final showColor = widget.enabled || widget.isActive; - final effectiveColor = showColor ? widget.color : colorScheme.onSurfaceVariant; - - return AnimatedBuilder( - animation: _pulseAnimation, - builder: (context, child) { - final bgOpacity = widget.isActive ? _pulseAnimation.value : 0.10; - - return Tooltip( - message: widget.tooltip, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: widget.enabled ? widget.onPressed : null, + // Keep the button's color (not grey) whenever the countdown badge is shown, + // mirroring portrait's `_ActionButton` showCooldown handling. Otherwise the + // badge renders white text on grey (onSurfaceVariant) during cooldown. + final showColor = + widget.enabled || widget.isActive || widget.countdown != null; + final effectiveColor = + showColor ? widget.color : colorScheme.onSurfaceVariant; + // Static active-state opacity (continuous pulse animation removed — see + // _ActionButton; it kept the GPU rendering all session). + final bgOpacity = widget.isActive ? 0.25 : 0.10; + + return Tooltip( + message: widget.tooltip, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: widget.enabled ? widget.onPressed : null, + borderRadius: BorderRadius.circular(12), + child: Container( + height: 48, + decoration: BoxDecoration( + color: effectiveColor.withValues(alpha: bgOpacity), borderRadius: BorderRadius.circular(12), - child: Container( - height: 48, - decoration: BoxDecoration( - color: effectiveColor.withValues(alpha: bgOpacity), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: effectiveColor.withValues(alpha: widget.isActive ? 0.5 : 0.25), - width: widget.isActive ? 1.5 : 1, - ), + border: Border.all( + color: effectiveColor.withValues( + alpha: widget.isActive ? 0.5 : 0.25), + width: widget.isActive ? 1.5 : 1, + ), + ), + child: Stack( + alignment: Alignment.center, + children: [ + // Main icon + Icon( + widget.icon, + size: 24, + color: showColor + ? effectiveColor + : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), - child: Stack( - alignment: Alignment.center, - children: [ - // Main icon - Icon( - widget.icon, - size: 24, - color: showColor ? effectiveColor : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), - ), - // Countdown badge (bottom right) - if (widget.countdown != null) - Positioned( - bottom: 4, - right: 4, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: effectiveColor, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - '${widget.countdown}', - style: const TextStyle( - fontSize: 9, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - ), + // Countdown badge (bottom right) + if (widget.countdown != null) + Positioned( + bottom: 4, + right: 4, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: effectiveColor, + borderRadius: BorderRadius.circular(6), ), - // Active indicator dot (top right) - if (widget.isActive && widget.countdown == null) - Positioned( - top: 4, - right: 4, - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: const Color(0xFF22C55E), - shape: BoxShape.circle, - border: Border.all(color: colorScheme.surface, width: 1.5), - ), + child: Text( + '${widget.countdown}', + style: const TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + color: Colors.white, ), ), - ], - ), - ), - ), - ), - ); - }, - ); - } -} - -/// Compact toggle button for landscape panel -class _LandscapeToggle extends StatelessWidget { - final IconData icon; - final String label; - final bool isOn; - final Color color; - final VoidCallback onTap; - - const _LandscapeToggle({ - required this.icon, - required this.label, - required this.isOn, - required this.color, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final activeColor = isOn ? color : colorScheme.onSurfaceVariant; - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(10), - child: Container( - height: 38, - padding: const EdgeInsets.symmetric(horizontal: 10), - decoration: BoxDecoration( - color: isOn - ? activeColor.withValues(alpha: 0.12) - : colorScheme.onSurface.withValues(alpha: 0.04), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: isOn - ? activeColor.withValues(alpha: 0.35) - : colorScheme.outline.withValues(alpha: 0.15), + ), + ), + // Active indicator dot (top right) + if (widget.isActive && widget.countdown == null) + Positioned( + top: 4, + right: 4, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: const Color(0xFF22C55E), + shape: BoxShape.circle, + border: + Border.all(color: colorScheme.surface, width: 1.5), + ), + ), + ), + ], ), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - icon, - size: 16, - color: isOn ? activeColor : colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 6), - Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: isOn ? FontWeight.w600 : FontWeight.w500, - color: isOn ? activeColor : colorScheme.onSurfaceVariant, - ), - ), - ], - ), ), ), ); } } +/// Compact toggle button for landscape panel /// Compact action button for minimized panel - horizontal pill layout /// Supports expanding to show label when active class _CompactActionButton extends StatefulWidget { @@ -1686,7 +1945,8 @@ class _CompactActionButton extends StatefulWidget { final bool isActive; final bool isExpanded; // When true, show icon + label with wider width final VoidCallback onPressed; - final double? progress; // 0.0 to 1.0 for progress bar fill, null = no progress bar + final double? + progress; // 0.0 to 1.0 for progress bar fill, null = no progress bar const _CompactActionButton({ required this.icon, @@ -1703,140 +1963,109 @@ class _CompactActionButton extends StatefulWidget { State<_CompactActionButton> createState() => _CompactActionButtonState(); } -class _CompactActionButtonState extends State<_CompactActionButton> - with SingleTickerProviderStateMixin { - late AnimationController _pulseController; - late Animation _pulseAnimation; - - @override - void initState() { - super.initState(); - _pulseController = AnimationController( - duration: const Duration(milliseconds: 1200), - vsync: this, - ); - _pulseAnimation = Tween(begin: 0.15, end: 0.35).animate( - CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), - ); - - if (widget.isActive) { - _pulseController.repeat(reverse: true); - } - } - - @override - void didUpdateWidget(_CompactActionButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.isActive && !oldWidget.isActive) { - _pulseController.repeat(reverse: true); - } else if (!widget.isActive && oldWidget.isActive) { - _pulseController.stop(); - _pulseController.reset(); - } - } - - @override - void dispose() { - _pulseController.dispose(); - super.dispose(); - } - +class _CompactActionButtonState extends State<_CompactActionButton> { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final showColor = widget.enabled || widget.isActive; - final effectiveColor = showColor ? widget.color : colorScheme.onSurfaceVariant; + final effectiveColor = + showColor ? widget.color : colorScheme.onSurfaceVariant; // Show label if colored OR if expanded (shows countdown on grey button during cooldown) final hasLabel = widget.label != null && (showColor || widget.isExpanded); + // Static active-state opacity (continuous pulse animation removed — see + // _ActionButton; it kept the GPU rendering all session). + final bgOpacity = widget.isActive ? 0.25 : 0.12; - return AnimatedBuilder( - animation: _pulseAnimation, - builder: (context, child) { - final bgOpacity = widget.isActive ? _pulseAnimation.value : 0.12; - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: widget.enabled ? widget.onPressed : null, - borderRadius: BorderRadius.circular(14), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - height: 32, - decoration: BoxDecoration( - color: effectiveColor.withValues(alpha: bgOpacity), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: effectiveColor.withValues(alpha: widget.isActive ? 0.5 : 0.3), - width: widget.isActive ? 1.5 : 1, - ), - ), - child: Stack( - children: [ - // Progress fill (behind content) - if (widget.progress != null && widget.progress! > 0) - Positioned.fill( - child: ClipRRect( - borderRadius: BorderRadius.circular(15), - child: FractionallySizedBox( - alignment: Alignment.centerLeft, - widthFactor: widget.progress!, - child: Container( - color: effectiveColor.withValues(alpha: 0.3), - ), - ), + return Material( + color: Colors.transparent, + child: InkWell( + onTap: widget.enabled ? widget.onPressed : null, + borderRadius: BorderRadius.circular(14), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + height: 32, + decoration: BoxDecoration( + color: effectiveColor.withValues(alpha: bgOpacity), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: + effectiveColor.withValues(alpha: widget.isActive ? 0.5 : 0.3), + width: widget.isActive ? 1.5 : 1, + ), + ), + child: Stack( + children: [ + // Progress fill (behind content) + if (widget.progress != null && widget.progress! > 0) + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular(15), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: widget.progress!, + child: Container( + color: effectiveColor.withValues(alpha: 0.3), ), ), - // Existing button content - Padding( - padding: EdgeInsets.symmetric( - horizontal: hasLabel ? 10 : 8, - vertical: 6, - ), - child: Center( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - widget.icon, - size: 18, - color: showColor ? effectiveColor : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), - ), - // Animated label - show when label is provided - AnimatedSize( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - child: hasLabel - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: 5), - Text( - widget.label!, - style: TextStyle( - fontSize: 11, - fontWeight: widget.isActive ? FontWeight.w600 : FontWeight.w500, - color: showColor ? effectiveColor : colorScheme.onSurfaceVariant.withValues(alpha: 0.5), - ), - ), - ], - ) - : const SizedBox.shrink(), - ), - ], + ), + ), + // Existing button content + Padding( + padding: EdgeInsets.symmetric( + horizontal: hasLabel ? 10 : 8, + vertical: 6, + ), + child: Center( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + widget.icon, + size: 18, + color: showColor + ? effectiveColor + : colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), ), - ), + // Animated label - show when label is provided + AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: hasLabel + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 5), + Text( + widget.label!, + style: TextStyle( + fontSize: 11, + fontWeight: widget.isActive + ? FontWeight.w600 + : FontWeight.w500, + color: showColor + ? effectiveColor + : colorScheme.onSurfaceVariant + .withValues(alpha: 0.5), + ), + ), + ], + ) + : const SizedBox.shrink(), + ), + ], ), ), - ], + ), ), - ), + ], ), - ); - }, + ), + ), ); } } diff --git a/lib/widgets/regional_config_card.dart b/lib/widgets/regional_config_card.dart index bdc9b312..b149cde9 100644 --- a/lib/widgets/regional_config_card.dart +++ b/lib/widgets/regional_config_card.dart @@ -8,6 +8,7 @@ class RegionalConfigCard extends StatelessWidget { final List channels; final String? scope; final bool isOfflineMode; + final bool compact; const RegionalConfigCard({ super.key, @@ -16,12 +17,18 @@ class RegionalConfigCard extends StatelessWidget { this.channels = const [], this.scope, this.isOfflineMode = false, + this.compact = false, }); @override Widget build(BuildContext context) { + if (compact) { + return _buildCompact(context); + } + // When offline mode is enabled, show "-" for zone fields - final displayZoneName = isOfflineMode ? '-' : (zoneName ?? 'Not configured'); + final displayZoneName = + isOfflineMode ? '-' : (zoneName ?? 'Not configured'); final displayZoneCode = isOfflineMode ? '-' : zoneCode; return Card( @@ -35,19 +42,22 @@ class RegionalConfigCard extends StatelessWidget { children: [ Icon( isOfflineMode ? Icons.cloud_off : Icons.public, - color: isOfflineMode ? Colors.orange : Theme.of(context).colorScheme.primary, + color: isOfflineMode + ? Colors.orange + : Theme.of(context).colorScheme.primary, ), const SizedBox(width: 8), Text( 'Regional Configuration', style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + fontWeight: FontWeight.bold, + ), ), if (isOfflineMode) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.orange, borderRadius: BorderRadius.circular(4), @@ -108,7 +118,96 @@ class RegionalConfigCard extends StatelessWidget { ); } - Widget _buildInfoRow(BuildContext context, IconData icon, String label, String? value, + /// Compact mode: "Regional Settings" header, scope row, channel chips + Widget _buildCompact(BuildContext context) { + final displayZone = isOfflineMode ? null : zoneName; + final displayScope = isOfflineMode ? '-' : (scope ?? 'Global'); + + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Icon( + Icons.public, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + 'Regional Settings', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + if (displayZone != null) + Text( + displayZone, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 10), + + // Scope row + _buildCompactRow(context, 'Scope', [ + _buildChannelChip(context, displayScope, isDefault: true), + ]), + const SizedBox(height: 8), + + // Channels row + _buildCompactRow(context, 'Channels', [ + _buildChannelChip(context, 'Public', isDefault: true), + _buildChannelChip(context, '#wardriving', isDefault: true), + if (!isOfflineMode) + ...channels.map((c) => _buildChannelChip(context, c)), + ]), + ], + ), + ), + ); + } + + /// Compact labeled row: small label on left, chips on right + Widget _buildCompactRow( + BuildContext context, String label, List chips) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 70, + child: Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + Expanded( + child: Wrap( + spacing: 6, + runSpacing: 6, + children: chips, + ), + ), + ], + ); + } + + Widget _buildInfoRow( + BuildContext context, IconData icon, String label, String? value, {bool isOffline = false}) { return Row( children: [ @@ -118,20 +217,30 @@ class RegionalConfigCard extends StatelessWidget { if (value != null) ...[ const SizedBox(width: 8), Expanded( - child: Text(value, style: TextStyle( - color: isOffline - ? Colors.orange.shade700 - : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), - )), + child: Text(value, + style: TextStyle( + color: isOffline + ? Colors.orange.shade700 + : Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), + )), ), ], ], ); } - Widget _buildChannelChip(BuildContext context, String name, {bool isDefault = false}) { - // Public channel doesn't use # prefix - final displayName = name == 'Public' ? name : (name.startsWith('#') ? name : '#$name'); + Widget _buildChannelChip(BuildContext context, String name, + {bool isDefault = false}) { + // Public channel doesn't use # prefix; scope/plain values pass through as-is + final displayName = + name == 'Public' ? name : (name.startsWith('#') ? name : '#$name'); + // If it doesn't look like a channel name, show raw value (e.g. scope "Global") + final isChannel = name.startsWith('#') || name == 'Public'; + final label = isChannel ? displayName : name; + return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( @@ -146,12 +255,13 @@ class RegionalConfigCard extends StatelessWidget { ), ), child: Text( - displayName, + label, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, - // Use onPrimaryContainer for proper contrast on primaryContainer background - color: isDefault ? Colors.grey : Theme.of(context).colorScheme.onPrimaryContainer, + color: isDefault + ? Colors.grey + : Theme.of(context).colorScheme.onPrimaryContainer, ), ), ); diff --git a/lib/widgets/repeater_id_chip.dart b/lib/widgets/repeater_id_chip.dart index 6d25d4d5..aeedbb05 100644 --- a/lib/widgets/repeater_id_chip.dart +++ b/lib/widgets/repeater_id_chip.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:geolocator/geolocator.dart'; import 'package:provider/provider.dart'; import '../models/repeater.dart'; @@ -7,6 +6,7 @@ import '../providers/app_state_provider.dart'; import '../services/gps_service.dart'; import '../utils/debug_logger_io.dart'; import '../utils/distance_formatter.dart'; +import '../utils/ping_colors.dart'; /// A styled repeater ID text with a dotted underline hint that it's tappable. /// @@ -23,47 +23,85 @@ class RepeaterIdChip extends StatelessWidget { /// Optional SizedBox width constraint (e.g., 50 or 60) final double? width; + /// When true, the info icon is rendered in amber to flag an ambiguous ID. + final bool isAmbiguous; + const RepeaterIdChip({ super.key, required this.repeaterId, this.fontSize = 11, this.width, + this.isAmbiguous = false, }); @override Widget build(BuildContext context) { // Scale font size down for longer IDs final effectiveFontSize = repeaterId.length > 4 - ? fontSize - 2.0 // 6-char IDs (3-byte) + ? fontSize - 2.0 // 6-char IDs (3-byte) : repeaterId.length > 2 - ? fontSize - 1.0 // 4-char IDs (2-byte) - : fontSize; // 2-char IDs (1-byte) + ? fontSize - 1.0 // 4-char IDs (2-byte) + : fontSize; // 2-char IDs (1-byte) - final child = Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - repeaterId, - style: TextStyle( - fontSize: effectiveFontSize, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(width: 2), - Icon( - Icons.info_outline, - size: fontSize - 1, - color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5), - ), - ], + final text = Text( + repeaterId, + softWrap: false, + overflow: TextOverflow.clip, + style: TextStyle( + fontSize: effectiveFontSize, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurface, + ), + ); + final icon = Icon( + Icons.info_outline, + size: fontSize - 1, + color: isAmbiguous + ? const Color(0xFFF59E0B) + : Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.5), ); + // Fixed-width form: the box can be narrower than the ID's intrinsic width + // (long 3-byte IDs), so wrap the text in Flexible to let it shrink/clip and + // avoid a sub-pixel RenderFlex overflow. Safe here because the SizedBox + // bounds the Row's width. if (width != null) { - return SizedBox(width: width, child: child); + return SizedBox( + width: width, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: text), + const SizedBox(width: 2), + icon, + ], + ), + ); } - return child; + + // Intrinsic-width form: usually laid out under unbounded width (e.g. as a + // non-flex child of another Row), where a Flexible child would throw — but + // it can also land inside a Flexible/bounded Row (the DISC node column), + // where a non-flex text overflows on long 3-byte IDs. Probe the incoming + // constraint: shrink/clip the text only when the parent bounds our width, + // otherwise keep it non-flex so it sizes to its content. + return LayoutBuilder( + builder: (context, constraints) { + final bounded = constraints.maxWidth.isFinite; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + bounded ? Flexible(child: text) : text, + const SizedBox(width: 2), + icon, + ], + ); + }, + ); } /// Show a dialog with matching repeater names for the given [repeaterId]. @@ -74,10 +112,26 @@ class RepeaterIdChip extends StatelessWidget { /// For DISC pings, pass [fullHexId] (the full public key hex) to enable /// exact 4-byte matching against the repeater database instead of the /// ambiguous 1-byte prefix match used for TX/RX pings. - static void showRepeaterPopup(BuildContext context, String repeaterId, {String? fullHexId}) { + /// Show a dialog with matching repeater names for the given [repeaterId]. + /// + /// When [fromLatLng] is provided, distances are measured from that point + /// (e.g. the ping's GPS location) instead of the user's current position. + static void showRepeaterPopup(BuildContext context, String repeaterId, + {String? fullHexId, ({double lat, double lon})? fromLatLng}) { final appState = Provider.of(context, listen: false); final repeaters = appState.repeaters; + final matchKey = fullHexId != null && fullHexId.length >= 8 + ? fullHexId.substring(0, 8) + : repeaterId; + final matchCount = repeaters + .where((r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) + .length; + debugLog('[UI] Repeater popup tap: id=$repeaterId, ' + 'fullHex=${fullHexId ?? "(none)"}, matchKey=$matchKey, ' + 'repeaters=${repeaters.length}, matches=$matchCount, ' + 'zone=${appState.zoneCode ?? "(none)"}'); + final Widget content; if (repeaters.isEmpty) { @@ -93,14 +147,9 @@ class RepeaterIdChip extends StatelessWidget { ), ); } else { - // DISC pings provide the full public key — match first 8 hex chars - // (4 bytes) against repeater hexId for exact identification. - // TX/RX pings only have 1-byte IDs so fall back to prefix matching. - final matchKey = fullHexId != null && fullHexId.length >= 8 - ? fullHexId.substring(0, 8) - : repeaterId; final matches = repeaters - .where((r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) + .where( + (r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) .toList(); if (matches.isEmpty) { @@ -116,26 +165,31 @@ class RepeaterIdChip extends StatelessWidget { ), ); } else { + // Use explicit origin point if provided, otherwise fall back to current GPS final position = appState.currentPosition; + final refLat = fromLatLng?.lat ?? position?.latitude; + final refLon = fromLatLng?.lon ?? position?.longitude; - // Sort by distance (closest first) when GPS is available - if (position != null) { + // Sort by distance (closest first) when a reference point is available + if (refLat != null && refLon != null) { matches.sort((a, b) { - final distA = GpsService.distanceBetween( - position.latitude, position.longitude, a.lat, a.lon, - ); - final distB = GpsService.distanceBetween( - position.latitude, position.longitude, b.lat, b.lon, - ); + final distA = + GpsService.distanceBetween(refLat, refLon, a.lat, a.lon); + final distB = + GpsService.distanceBetween(refLat, refLon, b.lat, b.lon); return distA.compareTo(distB); }); } - final regionOverride = appState.enforceHopBytes ? appState.effectiveHopBytes : null; + final regionOverride = + appState.enforceHopBytes ? appState.effectiveHopBytes : null; content = Column( mainAxisSize: MainAxisSize.min, children: matches - .map((r) => _buildRepeaterRow(context, r, position: position, regionHopBytesOverride: regionOverride)) + .map((r) => buildRepeaterRow(context, r, + refLat: refLat, + refLon: refLon, + regionHopBytesOverride: regionOverride)) .toList(), ); } @@ -192,24 +246,30 @@ class RepeaterIdChip extends StatelessWidget { ); } - static Widget _buildRepeaterRow( + static Widget buildRepeaterRow( BuildContext context, Repeater repeater, { - Position? position, + double? refLat, + double? refLon, int? regionHopBytesOverride, }) { final isActive = repeater.isActive; - final badgeColor = isActive ? Colors.green : Colors.grey; + final badgeColor = + isActive ? PingColors.repeaterActive : PingColors.repeaterDead; final statusText = isActive ? 'Active' : 'Stale'; + final statusIcon = isActive ? Icons.circle : Icons.circle_outlined; - // Calculate distance string if GPS is available + // Calculate distance string if a reference point is available String? distanceText; - if (position != null) { + if (refLat != null && refLon != null) { final meters = GpsService.distanceBetween( - position.latitude, position.longitude, repeater.lat, repeater.lon, + refLat, + refLon, + repeater.lat, + repeater.lon, ); debugLog('[UI] Distance to ${repeater.name}: ' - 'from (${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}) ' + 'from (${refLat.toStringAsFixed(5)}, ${refLon.toStringAsFixed(5)}) ' 'to (${repeater.lat.toStringAsFixed(5)}, ${repeater.lon.toStringAsFixed(5)}) ' '= ${meters.toStringAsFixed(0)}m'); final isImperial = Provider.of(context, listen: false) @@ -218,8 +278,7 @@ class RepeaterIdChip extends StatelessWidget { if (meters < 1000) { distanceText = formatMeters(meters, isImperial: isImperial); } else { - distanceText = - formatKilometers(meters / 1000, isImperial: isImperial); + distanceText = formatKilometers(meters / 1000, isImperial: isImperial); } } @@ -228,7 +287,9 @@ class RepeaterIdChip extends StatelessWidget { child: Row( children: [ // Colored badge — circle for short IDs, pill for longer - _buildHexBadge(repeater.displayHexId(overrideHopBytes: regionHopBytesOverride), badgeColor), + buildHexBadge( + repeater.displayHexId(overrideHopBytes: regionHopBytesOverride), + badgeColor), const SizedBox(width: 12), // Repeater name + distance subtitle Expanded( @@ -261,7 +322,8 @@ class RepeaterIdChip extends StatelessWidget { distanceText, style: TextStyle( fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: + Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], @@ -271,7 +333,7 @@ class RepeaterIdChip extends StatelessWidget { ), ), const SizedBox(width: 8), - // Active/Stale chip + // Active/Stale chip (icon + text for colorblind accessibility) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( @@ -279,13 +341,20 @@ class RepeaterIdChip extends StatelessWidget { borderRadius: BorderRadius.circular(10), border: Border.all(color: badgeColor.withValues(alpha: 0.4)), ), - child: Text( - statusText, - style: TextStyle( - fontSize: 11, - color: badgeColor, - fontWeight: FontWeight.w600, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(statusIcon, size: 8, color: badgeColor), + const SizedBox(width: 4), + Text( + statusText, + style: TextStyle( + fontSize: 11, + color: badgeColor, + fontWeight: FontWeight.w600, + ), + ), + ], ), ), ], @@ -294,15 +363,14 @@ class RepeaterIdChip extends StatelessWidget { } /// Build a hex ID badge — circle for 2-char, pill for longer IDs - static Widget _buildHexBadge(String displayId, Color color) { + static Widget buildHexBadge(String displayId, Color color) { final isLong = displayId.length > 2; return Container( constraints: const BoxConstraints(minWidth: 28), height: 28, - padding: isLong - ? const EdgeInsets.symmetric(horizontal: 5) - : EdgeInsets.zero, + padding: + isLong ? const EdgeInsets.symmetric(horizontal: 5) : EdgeInsets.zero, decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(14), diff --git a/lib/widgets/repeater_picker_sheet.dart b/lib/widgets/repeater_picker_sheet.dart new file mode 100644 index 00000000..e7258590 --- /dev/null +++ b/lib/widgets/repeater_picker_sheet.dart @@ -0,0 +1,370 @@ +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:provider/provider.dart'; + +import '../models/repeater.dart'; +import '../providers/app_state_provider.dart'; +import '../services/gps_service.dart'; +import '../utils/distance_formatter.dart'; +import '../utils/ping_colors.dart'; + +/// Show a bottom sheet repeater picker and return the selected repeater. +Future showRepeaterPicker(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.95, + minChildSize: 0.4, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) => _RepeaterPickerBody( + scrollController: scrollController, + ), + ), + ); +} + +class _RepeaterPickerBody extends StatefulWidget { + final ScrollController scrollController; + + const _RepeaterPickerBody({required this.scrollController}); + + @override + State<_RepeaterPickerBody> createState() => _RepeaterPickerBodyState(); +} + +class _RepeaterPickerBodyState extends State<_RepeaterPickerBody> { + final _searchController = TextEditingController(); + String _query = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + List _filterAndSort(List repeaters, Position? position) { + final q = _query.toLowerCase(); + List filtered; + if (q.isNotEmpty) { + filtered = repeaters + .where((r) => + r.name.toLowerCase().contains(q) || + r.hexId.toLowerCase().startsWith(q)) + .toList(); + } else { + filtered = List.of(repeaters); + } + + // When searching, a hex-prefix match is almost always the intended result, + // so it must rank above a repeater that merely has the query somewhere in + // its name. Without this, an active/closer name match sorts to the top and + // looks like the search returned the wrong ID (e.g. "6a" surfacing "63" + // because that node's name contains "6a") (#382). + int hexRank(Repeater r) => + q.isNotEmpty && r.hexId.toLowerCase().startsWith(q) ? 0 : 1; + + // Sort: hex-prefix matches first, then active, then by distance (if GPS), + // then alphabetically + filtered.sort((a, b) { + // Hex-prefix matches first (only affects ordering while searching) + final byRelevance = hexRank(a).compareTo(hexRank(b)); + if (byRelevance != 0) return byRelevance; + // Active first + if (a.isActive != b.isActive) return a.isActive ? -1 : 1; + // By distance if GPS available + if (position != null) { + final distA = GpsService.distanceBetween( + position.latitude, + position.longitude, + a.lat, + a.lon, + ); + final distB = GpsService.distanceBetween( + position.latitude, + position.longitude, + b.lat, + b.lon, + ); + return distA.compareTo(distB); + } + // Alphabetically + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + }); + + return filtered; + } + + @override + Widget build(BuildContext context) { + final appState = context.watch(); + final repeaters = appState.repeaters; + final position = appState.currentPosition; + final isImperial = appState.preferences.isImperial; + final colorScheme = Theme.of(context).colorScheme; + + final filtered = _filterAndSort(repeaters, position); + + return Column( + children: [ + // Drag handle + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon(Icons.cell_tower, size: 20, color: colorScheme.primary), + const SizedBox(width: 8), + Text( + 'Select Repeater', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + // Search field + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search by name or hex ID...', + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: _query.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + onPressed: () { + _searchController.clear(); + setState(() => _query = ''); + }, + ) + : null, + isDense: true, + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onChanged: (value) => setState(() => _query = value), + ), + ), + // Count label + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Showing ${filtered.length} of ${repeaters.length} repeaters', + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + const Divider(height: 1), + // List + Expanded( + child: filtered.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + repeaters.isEmpty + ? 'No repeaters available' + : 'No repeaters match your search', + style: TextStyle( + fontSize: 14, + fontStyle: FontStyle.italic, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ) + : ListView.builder( + controller: widget.scrollController, + itemCount: filtered.length, + itemBuilder: (context, index) { + final r = filtered[index]; + return _RepeaterTile( + repeater: r, + position: position, + isImperial: isImperial, + onTap: () => Navigator.pop(context, r), + ); + }, + ), + ), + ], + ); + } +} + +class _RepeaterTile extends StatelessWidget { + final Repeater repeater; + final Position? position; + final bool isImperial; + final VoidCallback onTap; + + const _RepeaterTile({ + required this.repeater, + required this.position, + required this.isImperial, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isActive = repeater.isActive; + final badgeColor = + isActive ? PingColors.repeaterActive : PingColors.repeaterDead; + final statusIcon = isActive ? Icons.circle : Icons.circle_outlined; + + // Distance text + String? distanceText; + if (position != null) { + final meters = GpsService.distanceBetween( + position!.latitude, + position!.longitude, + repeater.lat, + repeater.lon, + ); + if (meters < 1000) { + distanceText = formatMeters(meters, isImperial: isImperial); + } else { + distanceText = formatKilometers(meters / 1000, isImperial: isImperial); + } + } + + // Always show 4-byte (8-char) hex ID for identification + final displayHex = repeater.hexId.length >= 8 + ? repeater.hexId.substring(0, 8).toUpperCase() + : repeater.hexId.toUpperCase(); + + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + // Hex badge + Container( + constraints: const BoxConstraints(minWidth: 28), + height: 28, + padding: const EdgeInsets.symmetric(horizontal: 5), + decoration: BoxDecoration( + color: badgeColor, + borderRadius: BorderRadius.circular(14), + ), + alignment: Alignment.center, + child: Text( + displayHex, + style: TextStyle( + fontSize: displayHex.length > 4 ? 8 : 10, + fontWeight: FontWeight.bold, + color: Colors.white, + fontFamily: 'monospace', + ), + ), + ), + const SizedBox(width: 12), + // Name + distance + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + repeater.name, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + ), + if (distanceText != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.near_me, + size: 10, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 3), + Text( + distanceText, + style: TextStyle( + fontSize: 11, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(width: 8), + // Active/Stale chip (icon + text for colorblind accessibility) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: badgeColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: badgeColor.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(statusIcon, size: 8, color: badgeColor), + const SizedBox(width: 4), + Text( + isActive ? 'Active' : 'Stale', + style: TextStyle( + fontSize: 11, + color: badgeColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/rx_path_chain.dart b/lib/widgets/rx_path_chain.dart new file mode 100644 index 00000000..9962d2c8 --- /dev/null +++ b/lib/widgets/rx_path_chain.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +import 'repeater_id_chip.dart'; + +/// Compact arrow-chain renderer for an RX packet's hop path. +/// +/// Renders `[hop] → [hop] → [hop] (heard)` with each hop as a tappable +/// [RepeaterIdChip]. Long paths wrap to multiple lines via [Wrap]. +class RxPathChain extends StatelessWidget { + /// Hop list ordered origin → ... → us. Already CARpeater-stripped. + final List hops; + + /// Source GPS coordinates passed through to the repeater info popup so + /// distances are measured from the ping location, not the user's current + /// position. + final ({double lat, double lon})? fromLatLng; + + /// Font size for hop chips (11 for log screens, 13 for map popups). + final double fontSize; + + const RxPathChain({ + super.key, + required this.hops, + this.fromLatLng, + this.fontSize = 12, + }); + + @override + Widget build(BuildContext context) { + if (hops.isEmpty) return const SizedBox.shrink(); + + final scheme = Theme.of(context).colorScheme; + final arrow = Icon( + Icons.arrow_forward, + size: fontSize, + color: scheme.onSurfaceVariant.withValues(alpha: 0.7), + ); + + final children = []; + for (var i = 0; i < hops.length; i++) { + if (i > 0) children.add(arrow); + children.add( + InkWell( + onTap: () => RepeaterIdChip.showRepeaterPopup( + context, + hops[i], + fromLatLng: fromLatLng, + ), + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: RepeaterIdChip(repeaterId: hops[i], fontSize: fontSize), + ), + ), + ); + } + children.add( + Padding( + padding: const EdgeInsets.only(left: 4), + child: Text( + '(heard)', + style: TextStyle( + fontSize: fontSize - 2, + fontStyle: FontStyle.italic, + color: scheme.onSurfaceVariant, + ), + ), + ), + ); + + return Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 2, + runSpacing: 4, + children: children, + ); + } +} diff --git a/lib/widgets/status_bar.dart b/lib/widgets/status_bar.dart index 90fa4aff..41c6ca93 100644 --- a/lib/widgets/status_bar.dart +++ b/lib/widgets/status_bar.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../models/connection_state.dart'; import '../providers/app_state_provider.dart'; import '../utils/distance_formatter.dart'; +import '../utils/ping_colors.dart'; /// Status bar showing GPS, connection, and queue status class StatusBar extends StatefulWidget { @@ -57,7 +58,8 @@ class _StatusBarState extends State { borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => Padding( - padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + MediaQuery.of(context).viewPadding.bottom), + padding: EdgeInsets.fromLTRB( + 20, 20, 20, 20 + MediaQuery.of(context).viewPadding.bottom), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -118,48 +120,113 @@ class _StatusBarState extends State { } /// Get content for the popup based on ID - (String, String, IconData, Color) _getPopupContent(String id, AppStateProvider appState) { + (String, String, IconData, Color) _getPopupContent( + String id, AppStateProvider appState) { switch (id) { case 'gps': if (appState.offlineMode) { - return ('Offline Mode Active', 'Pings are saved locally. Zone detection is paused until you go back online.', Icons.flight, Colors.grey); + return ( + 'Offline Mode Active', + 'Pings are saved locally. Zone detection is paused until you go back online.', + Icons.flight, + Colors.grey + ); } if (appState.inZone == true && appState.zoneCode != null) { if (!appState.isConnected) { - return ('${appState.zoneName ?? appState.zoneCode} Zone', + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', 'You\'re in an authorized zone. Connect to a device to start wardriving.', - Icons.flight, Colors.grey); + Icons.flight, + Colors.grey + ); + } + // Region veto comes first: a flood-disabled region reports 0 TX slots, + // which would otherwise read as "zone full" (red) instead of a + // deliberate regional setting (blue). + if (appState.floodDisabled) { + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', + 'Your regional admin has turned off flood traffic here, so Active and Hybrid modes are unavailable. The zone is not full. Passive Mode and Trace Mode still work.', + Icons.flight, + Colors.blue + ); } if (!appState.txAllowed) { - return ('${appState.zoneName ?? appState.zoneCode} Zone', + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', 'You\'re in an authorized zone. However, the zone is at Active Wardrive capacity. You can still wardrive, but only Passive Mode is allowed.', - Icons.flight, Colors.red); + Icons.flight, + Colors.red + ); } - return ('${appState.zoneName ?? appState.zoneCode} Zone', + return ( + '${appState.zoneName ?? appState.zoneCode} Zone', 'You\'re in an active zone with ${appState.zoneSlotsAvailable ?? "?"} TX slots available. Ready to wardrive!', - Icons.flight, Colors.green); + Icons.flight, + Colors.green + ); } if (appState.inZone == false) { final nearest = appState.nearestZoneName ?? 'Unknown'; final distKm = appState.nearestZoneDistanceKm; final dist = distKm != null - ? formatKilometers(distKm, isImperial: appState.preferences.isImperial) + ? formatKilometers(distKm, + isImperial: appState.preferences.isImperial) : '?'; - return ('Outside Coverage Area', 'Nearest zone is $nearest, $dist away. Enter a zone to start wardriving.', Icons.flight, Colors.orange); + return ( + 'Outside Coverage Area', + 'Nearest zone is $nearest, $dist away. Enter a zone to start wardriving.', + Icons.flight, + Colors.orange + ); } - return ('Locating...', 'Acquiring GPS signal and checking your zone status.', Icons.gps_not_fixed, Colors.blue); + return ( + 'Locating...', + 'Acquiring GPS signal and checking your zone status.', + Icons.gps_not_fixed, + Colors.blue + ); case 'tx': - return ('TX Packets', 'TX packets that have been sent out. These are messages to the #wardriving channel.', Icons.arrow_upward, Colors.green); + return ( + 'TX Packets', + 'TX packets that have been sent out. These are messages to the #wardriving channel.', + Icons.arrow_upward, + PingColors.txSuccess + ); case 'rx': - return ('RX Packets', 'RX packets that we have heard from the mesh. These were not initiated by us.', Icons.arrow_downward, Colors.blue); + return ( + 'RX Packets', + 'RX packets that we have heard from the mesh. These were not initiated by us.', + Icons.arrow_downward, + PingColors.rx + ); case 'disc': - return ('Discovery Requests', 'Discovery requests we have heard a response for.', Icons.radar, const Color(0xFF7B68EE)); + return ( + 'Discovery Requests', + 'Discovery requests we have heard a response for.', + Icons.radar, + PingColors.discSuccess + ); + + case 'trace': + return ( + 'Trace Responses', + 'Trace path requests that received a response from the target repeater.', + Icons.route, + PingColors.traceSuccess + ); case 'upload': - return ('Uploaded', 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', Icons.cloud_done, Colors.teal); + return ( + 'Uploaded', + 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', + Icons.cloud_done, + Colors.teal + ); default: return ('Info', '', Icons.info, Colors.grey); @@ -176,7 +243,11 @@ class _StatusBarState extends State { icon = Icons.flight; color = Colors.grey; text = '-'; - return _buildStatChip(icon: icon, value: text, color: color, onTap: () => _showInfoPopup(context, 'gps')); + return _buildStatChip( + icon: icon, + value: text, + color: color, + onTap: () => _showInfoPopup(context, 'gps')); } // Show GPS region (e.g., "YOW") when locked and inside a zone @@ -186,8 +257,13 @@ class _StatusBarState extends State { if (appState.inZone == true && appState.zoneCode != null) { icon = Icons.flight; color = appState.isConnected - ? (appState.txAllowed ? Colors.green : Colors.red) - : Colors.grey; // Grey when not connected, red when zone is at TX capacity + ? (appState.floodDisabled + ? Colors.blue // Region veto: passive only, not an error + : appState.txAllowed + ? Colors.green + : Colors.red) + : Colors + .grey; // Grey when not connected, red when zone is at TX capacity text = appState.zoneCode!; } else if (appState.inZone == false) { // GPS locked but outside any zone @@ -225,7 +301,11 @@ class _StatusBarState extends State { break; } - return _buildStatChip(icon: icon, value: text, color: color, onTap: () => _showInfoPopup(context, 'gps')); + return _buildStatChip( + icon: icon, + value: text, + color: color, + onTap: () => _showInfoPopup(context, 'gps')); } Widget _buildStatsIndicator(BuildContext context, AppStateProvider appState) { @@ -236,7 +316,7 @@ class _StatusBarState extends State { _AnimatedStatChip( icon: Icons.arrow_upward, value: appState.pingStats.txCount, - color: Colors.green, + color: PingColors.txSuccess, onTap: () => _showInfoPopup(context, 'tx'), ), @@ -246,7 +326,7 @@ class _StatusBarState extends State { _AnimatedStatChip( icon: Icons.arrow_downward, value: appState.pingStats.rxCount, - color: Colors.blue, + color: PingColors.rx, onTap: () => _showInfoPopup(context, 'rx'), ), @@ -256,12 +336,22 @@ class _StatusBarState extends State { _AnimatedStatChip( icon: Icons.radar, value: appState.pingStats.discCount, - color: const Color(0xFF7B68EE), // DISC purple + color: PingColors.discSuccess, onTap: () => _showInfoPopup(context, 'disc'), ), const SizedBox(width: 8), + // Trace count chip (animated) + _AnimatedStatChip( + icon: Icons.route, + value: appState.pingStats.traceCount, + color: PingColors.traceSuccess, + onTap: () => _showInfoPopup(context, 'trace'), + ), + + const SizedBox(width: 8), + // Uploaded count chip (animated) _AnimatedStatChip( icon: Icons.cloud_done, @@ -378,7 +468,8 @@ class _AnimatedStatChipState extends State<_AnimatedStatChip> child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: widget.color.withValues(alpha: _highlightAnimation.value), + color: + widget.color.withValues(alpha: _highlightAnimation.value), borderRadius: BorderRadius.circular(8), border: Border.all(color: widget.color.withValues(alpha: 0.4)), ), diff --git a/lib/widgets/upload_logs_dialog.dart b/lib/widgets/upload_logs_dialog.dart index f97ddae1..99660b15 100644 --- a/lib/widgets/upload_logs_dialog.dart +++ b/lib/widgets/upload_logs_dialog.dart @@ -64,7 +64,7 @@ class _UploadLogsSheetState extends State { Future _loadUploadableFiles() async { try { - final files = await DebugFileLogger.listUploadableLogFiles(); + final files = await widget.appState.prepareDebugLogsForUpload(); if (mounted) { setState(() { _availableLogFiles = files; @@ -162,15 +162,16 @@ class _UploadLogsSheetState extends State { // Build the upload list using the user's selection applied to the freshly rotated files. // Selected paths from before rotation still match, plus any newly rotated file is included. final selectedPaths = Set.from(_selectedLogFiles); - final filesToUpload = freshFiles - .where((f) => selectedPaths.contains(f.path)) - .toList(); + final filesToUpload = + freshFiles.where((f) => selectedPaths.contains(f.path)).toList(); // If the rotation produced a new file that wasn't in the original selection // (i.e. the previously-active log that just got rotated), include it too // since the user selected "all" initially and this file has new content. - final newFiles = freshFiles.where((f) => !selectedPaths.contains(f.path)).toList(); - if (newFiles.isNotEmpty && selectedPaths.length == _availableLogFiles.length) { + final newFiles = + freshFiles.where((f) => !selectedPaths.contains(f.path)).toList(); + if (newFiles.isNotEmpty && + selectedPaths.length == _availableLogFiles.length) { filesToUpload.addAll(newFiles); } @@ -191,7 +192,8 @@ class _UploadLogsSheetState extends State { final publicKey = widget.appState.devicePublicKey ?? widget.appState.lastConnectedPublicKey ?? 'not-connected'; - final deviceName = widget.appState.lastConnectedDeviceName ?? 'not-connected'; + final deviceName = + widget.appState.lastConnectedDeviceName ?? 'not-connected'; final userNotes = _descriptionController.text.trim(); int uploadedCount = 0; @@ -220,7 +222,8 @@ class _UploadLogsSheetState extends State { onProgress: (p) { _onProgressUpdate(BugReportProgress( status: p.status, - progress: (progressBase + p.progress * progressPerFile).clamp(0.0, 1.0), + progress: + (progressBase + p.progress * progressPerFile).clamp(0.0, 1.0), currentFile: i + 1, totalFiles: totalFiles, )); @@ -242,7 +245,8 @@ class _UploadLogsSheetState extends State { success: uploadedCount > 0, uploadedCount: uploadedCount, failedCount: failedCount, - errorMessage: failedCount > 0 ? '$failedCount file(s) failed to upload' : null, + errorMessage: + failedCount > 0 ? '$failedCount file(s) failed to upload' : null, ); Navigator.of(context).pop(result); @@ -287,13 +291,15 @@ class _UploadLogsSheetState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), child: Row( children: [ - Icon(Icons.cloud_upload_outlined, color: theme.colorScheme.primary, size: 28), + Icon(Icons.cloud_upload_outlined, + color: theme.colorScheme.primary, size: 28), const SizedBox(width: 12), Text('Upload Logs', style: theme.textTheme.titleLarge), const Spacer(), IconButton( icon: const Icon(Icons.close), - onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), + onPressed: + _isSubmitting ? null : () => Navigator.of(context).pop(), tooltip: 'Close', ), ], @@ -312,13 +318,15 @@ class _UploadLogsSheetState extends State { child: ListView( controller: widget.scrollController, padding: const EdgeInsets.all(20), - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, children: [ // Explanation text Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3), + color: theme.colorScheme.primaryContainer + .withValues(alpha: 0.3), borderRadius: BorderRadius.circular(12), border: Border.all( color: theme.colorScheme.primary.withValues(alpha: 0.3), @@ -355,7 +363,8 @@ class _UploadLogsSheetState extends State { textCapitalization: TextCapitalization.sentences, decoration: _buildInputDecoration( theme, - hintText: 'Briefly describe why you\'re uploading these logs...', + hintText: + 'Briefly describe why you\'re uploading these logs...', alignLabelWithHint: true, ), maxLines: 3, @@ -381,10 +390,12 @@ class _UploadLogsSheetState extends State { Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), border: Border.all( - color: theme.colorScheme.outline.withValues(alpha: 0.3), + color: + theme.colorScheme.outline.withValues(alpha: 0.3), ), ), child: Row( @@ -411,10 +422,12 @@ class _UploadLogsSheetState extends State { Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), border: Border.all( - color: theme.colorScheme.outline.withValues(alpha: 0.3), + color: + theme.colorScheme.outline.withValues(alpha: 0.3), ), ), child: Row( @@ -437,17 +450,20 @@ class _UploadLogsSheetState extends State { else Container( decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + color: theme.colorScheme.surfaceContainerHighest + .withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), border: Border.all( - color: theme.colorScheme.outline.withValues(alpha: 0.3), + color: + theme.colorScheme.outline.withValues(alpha: 0.3), ), ), child: Column( children: [ // Select all / deselect all header Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), child: Row( children: [ Text( @@ -460,7 +476,8 @@ class _UploadLogsSheetState extends State { TextButton( onPressed: () { setState(() { - if (_selectedLogFiles.length == _availableLogFiles.length) { + if (_selectedLogFiles.length == + _availableLogFiles.length) { _selectedLogFiles.clear(); } else { _selectedLogFiles.clear(); @@ -471,7 +488,8 @@ class _UploadLogsSheetState extends State { }); }, child: Text( - _selectedLogFiles.length == _availableLogFiles.length + _selectedLogFiles.length == + _availableLogFiles.length ? 'Deselect All' : 'Select All', ), @@ -481,22 +499,28 @@ class _UploadLogsSheetState extends State { ), Divider( height: 1, - color: theme.colorScheme.outline.withValues(alpha: 0.3), + color: theme.colorScheme.outline + .withValues(alpha: 0.3), ), // File list ...List.generate(_availableLogFiles.length, (index) { final file = _availableLogFiles[index]; final filename = file.path.split('/').last; final sizeBytes = file.lengthSync(); - final isSelected = _selectedLogFiles.contains(file.path); + final isSelected = + _selectedLogFiles.contains(file.path); String sizeDisplay; - final partCount = DebugFileLogger.estimatePartCount(sizeBytes); - if (sizeBytes >= DebugFileLogger.maxUploadSizeBytes) { - final sizeMb = (sizeBytes / 1024 / 1024).toStringAsFixed(1); + final partCount = + DebugFileLogger.estimatePartCount(sizeBytes); + if (sizeBytes >= + DebugFileLogger.maxUploadSizeBytes) { + final sizeMb = + (sizeBytes / 1024 / 1024).toStringAsFixed(1); sizeDisplay = '$sizeMb MB ($partCount parts)'; } else { - sizeDisplay = '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; + sizeDisplay = + '${(sizeBytes / 1024).toStringAsFixed(1)} KB'; } return ListTile( @@ -512,9 +536,11 @@ class _UploadLogsSheetState extends State { style: const TextStyle(fontSize: 13), ), trailing: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, + color: + theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), child: Text( @@ -524,7 +550,9 @@ class _UploadLogsSheetState extends State { ), ), ), - onTap: _isSubmitting ? null : () => _toggleFile(file.path), + onTap: _isSubmitting + ? null + : () => _toggleFile(file.path), ); }), ], @@ -589,7 +617,8 @@ class _UploadLogsSheetState extends State { children: [ Expanded( child: OutlinedButton( - onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), + onPressed: + _isSubmitting ? null : () => Navigator.of(context).pop(), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 12), ), @@ -642,7 +671,8 @@ class _UploadLogsSheetState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), child: Row( children: [ - Icon(Icons.cloud_upload_outlined, color: theme.colorScheme.primary, size: 28), + Icon(Icons.cloud_upload_outlined, + color: theme.colorScheme.primary, size: 28), const SizedBox(width: 12), Text('Uploading...', style: theme.textTheme.titleLarge), ], @@ -663,7 +693,8 @@ class _UploadLogsSheetState extends State { width: 80, height: 80, decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3), + color: theme.colorScheme.primaryContainer + .withValues(alpha: 0.3), shape: BoxShape.circle, ), child: Center( @@ -678,16 +709,16 @@ class _UploadLogsSheetState extends State { ), ), const SizedBox(height: 32), - Text( - _progressStatus.isNotEmpty ? _progressStatus : 'Please wait...', + _progressStatus.isNotEmpty + ? _progressStatus + : 'Please wait...', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), textAlign: TextAlign.center, ), const SizedBox(height: 8), - if (_totalFiles != null && _currentFile != null) Text( 'File $_currentFile of $_totalFiles', @@ -696,7 +727,6 @@ class _UploadLogsSheetState extends State { ), ), const SizedBox(height: 24), - SizedBox( width: 250, child: Column( @@ -705,7 +735,8 @@ class _UploadLogsSheetState extends State { borderRadius: BorderRadius.circular(6), child: LinearProgressIndicator( value: _progress, - backgroundColor: theme.colorScheme.surfaceContainerHighest, + backgroundColor: + theme.colorScheme.surfaceContainerHighest, color: theme.colorScheme.primary, minHeight: 8, ), diff --git a/pubspec.lock b/pubspec.lock index 5b882162..0bb6f3ab 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,38 @@ packages: url: "https://pub.dev" source: hosted version: "6.4.1" + app_links: + dependency: "direct main" + description: + name: app_links + sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" archive: dependency: transitive description: @@ -249,6 +281,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + disk_space_plus: + dependency: "direct main" + description: + name: disk_space_plus + sha256: "1e454599a7dcec0c0bc1ad0b7f9f563b3fffbc988175bd452c4d3c7503ed3423" + url: "https://pub.dev" + source: hosted + version: "0.2.6" equatable: dependency: transitive description: @@ -258,7 +298,7 @@ packages: source: hosted version: "2.0.8" fake_async: - dependency: transitive + dependency: "direct dev" description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" @@ -390,6 +430,13 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" + flutter_carplay: + dependency: "direct main" + description: + path: "third_party/flutter_carplay" + relative: true + source: path + version: "1.6.5" flutter_launcher_icons: dependency: "direct dev" description: @@ -430,30 +477,70 @@ packages: url: "https://pub.dev" source: hosted version: "7.2.0" - flutter_map: - dependency: "direct main" + flutter_plugin_android_lifecycle: + dependency: transitive description: - name: flutter_map - sha256: "87cc8349b8fa5dccda5af50018c7374b6645334a0d680931c1fe11bce88fa5bb" + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 url: "https://pub.dev" source: hosted - version: "6.2.1" - flutter_map_cancellable_tile_provider: + version: "2.0.33" + flutter_secure_storage: dependency: "direct main" description: - name: flutter_map_cancellable_tile_provider - sha256: ae18dd59faf74f3eca1d28f83e59b47741bbff962e123bbebe9335c04d432f44 + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" url: "https://pub.dev" source: hosted - version: "2.0.0" - flutter_plugin_android_lifecycle: + version: "9.2.4" + flutter_secure_storage_linux: dependency: transitive description: - name: flutter_plugin_android_lifecycle - sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 url: "https://pub.dev" source: hosted - version: "2.0.33" + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -476,10 +563,10 @@ packages: dependency: "direct main" description: name: font_awesome_flutter - sha256: b9011df3a1fa02993630b8fb83526368cf2206a711259830325bab2f1d2a4eb0 + sha256: "09dcde8ab90ffae1a7d65ff2ef96fc62a17ad9d0ce7c127b317ded676b0d5935" url: "https://pub.dev" source: hosted - version: "10.12.0" + version: "11.0.0" frontend_server_client: dependency: transitive description: @@ -552,6 +639,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + gtk: + dependency: transitive + description: + name: gtk + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" + url: "https://pub.dev" + source: hosted + version: "2.2.0" hive: dependency: "direct main" description: @@ -636,10 +731,10 @@ packages: dependency: transitive description: name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.6.7" json_annotation: dependency: transitive description: @@ -672,14 +767,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.16" - latlong2: - dependency: "direct main" - description: - name: latlong2 - sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" - url: "https://pub.dev" - source: hosted - version: "0.9.1" leak_tracker: dependency: transitive description: @@ -712,30 +799,37 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" - lists: + logging: dependency: transitive description: - name: lists - sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 url: "https://pub.dev" source: hosted - version: "1.0.1" - logger: + version: "1.3.0" + maplibre_gl: + dependency: "direct main" + description: + path: "third_party/maplibre_gl" + relative: true + source: path + version: "0.25.0" + maplibre_gl_platform_interface: dependency: transitive description: - name: logger - sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + name: maplibre_gl_platform_interface + sha256: bd7de401dea24dd7e8a6f2fa736ddee7dbbee3e24a9027f0afdd619994702047 url: "https://pub.dev" source: hosted - version: "2.6.2" - logging: + version: "0.25.0" + maplibre_gl_web: dependency: transitive description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + name: maplibre_gl_web + sha256: af0e48bf96e8dd99f8b958a1953126971eb8a0527b9735441d4f24df3913f5a2 url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "0.25.0" matcher: dependency: transitive description: @@ -760,14 +854,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" - mgrs_dart: - dependency: transitive - description: - name: mgrs_dart - sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 - url: "https://pub.dev" - source: hosted - version: "2.0.0" mime: dependency: transitive description: @@ -832,6 +918,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -960,14 +1054,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" - polylabel: - dependency: transitive - description: - name: polylabel - sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b" - url: "https://pub.dev" - source: hosted - version: "1.0.1" pool: dependency: transitive description: @@ -984,14 +1070,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.3" - proj4dart: - dependency: transitive - description: - name: proj4dart - sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e - url: "https://pub.dev" - source: hosted - version: "2.1.0" provider: dependency: "direct main" description: @@ -1221,14 +1299,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - unicode: - dependency: transitive - description: - name: unicode - sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" - url: "https://pub.dev" - source: hosted - version: "0.3.1" url_launcher: dependency: "direct main" description: @@ -1297,10 +1367,34 @@ packages: dependency: "direct main" description: name: uuid - sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935" + url: "https://pub.dev" + source: hosted + version: "1.2.3" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" url: "https://pub.dev" source: hosted - version: "4.5.2" + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682" + url: "https://pub.dev" + source: hosted + version: "1.3.0" vector_math: dependency: transitive description: @@ -1373,14 +1467,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.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: diff --git a/pubspec.yaml b/pubspec.yaml index 60b10a0e..4f01c386 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ publish_to: 'none' version: 1.0.0+1 environment: - sdk: '>=3.2.0 <4.0.0' + sdk: '>=3.6.0 <4.0.0' dependencies: flutter: @@ -13,9 +13,7 @@ dependencies: flutter_blue_plus: ^1.32.0 flutter_web_bluetooth: ^0.2.3 geolocator: ^11.0.0 - flutter_map: ^6.1.0 - flutter_map_cancellable_tile_provider: ^2.0.0 - latlong2: ^0.9.0 + maplibre_gl: ^0.25.0 http: ^1.2.0 shared_preferences: ^2.2.0 hive: ^2.2.3 @@ -37,9 +35,16 @@ dependencies: just_audio: ^0.9.40 audio_session: ^0.1.21 dio: ^5.0.0 - font_awesome_flutter: ^10.7.0 + font_awesome_flutter: ^11.0.0 fl_chart: ^0.69.0 web: ^1.1.0 + disk_space_plus: ^0.2.3 + # Custom-scheme deep links for the MyMeshMapper portal sign-in callback + # (meshmapper-auth://callback). uni_links was rejected — unmaintained. + app_links: ^6.3.0 + # Keychain / Android Keystore storage for the portal app token + flutter_secure_storage: ^9.2.0 + flutter_carplay: ^1.6.5 dev_dependencies: flutter_test: @@ -48,6 +53,37 @@ dev_dependencies: build_runner: ^2.4.0 hive_generator: ^2.0.0 flutter_launcher_icons: ^0.14.3 + fake_async: ^1.3.3 + +# Vendored, patched copy of maplibre_gl 0.25.0. The ONLY delta from the +# upstream pub.dev release is a native guard in the camera handlers +# (ios/.../MapLibreMapController.swift + android/.../MapLibreMapController.java) +# that refuses camera moves while the map view has no usable viewport — +# MapLibre's transform unprojects against the viewport and aborts the app with +# an uncaught C++ std::domain_error (SIGABRT) when it is degenerate/zero-sized. +# That throw cannot be caught from Dart, so the fix must live in the plugin. +# Re-apply the guard when bumping maplibre_gl. See DEVELOPMENT.md. +# Vendored, patched copy of flutter_carplay 1.6.5, used for its Android Auto +# half only. Three deltas from the upstream pub.dev release, all marked "DELTA" +# in the vendored source: +# A. The `ios:` plugin platform entry and the ios/ directory are removed. +# MeshMapper ships on the App Store; upstream would link +# SwiftFlutterCarplayPlugin into Runner for a CarPlay surface we neither +# want nor hold Apple's entitlement for. +# B. AndroidAutoService gains FAAEngineProvider, so the app can supply the +# engine. Upstream builds a bare headless one when the cache is empty, +# which in this app is the wrong engine — ours owns the USB serial and +# tile cache channels (see MeshMapperEngine.kt). +# C. createHostValidator no longer returns ALLOW_ALL_HOSTS_VALIDATOR in +# release builds; that is debug-only per Android's docs and lets any app +# on the device drive the car surface. +# example/, previews/ and test/ are not vendored. Re-apply all three deltas when +# bumping flutter_carplay. See DEVELOPMENT.md. +dependency_overrides: + maplibre_gl: + path: third_party/maplibre_gl + flutter_carplay: + path: third_party/flutter_carplay flutter_launcher_icons: android: true @@ -65,3 +101,9 @@ flutter: - assets/wardrive.png - assets/transmitted_packet.mp3 - assets/received_packet.mp3 + # Android Auto map action strip icons. The strip is icon-only — + # ACTIONS_CONSTRAINTS_MAP leaves maxCustomTitles at 0 — so these are not + # decoration, they are the buttons. White on transparent so the host tints + # them to its own theme. + - assets/car_start.png + - assets/car_stop.png diff --git a/set_whats_new.sh b/set_whats_new.sh new file mode 100755 index 00000000..c7ce669a --- /dev/null +++ b/set_whats_new.sh @@ -0,0 +1,462 @@ +#!/bin/bash + +# MeshMapper TestFlight "What to Test" script +# Sets the per-build "What to Test" text on a TestFlight build through the +# App Store Connect API (betaBuildLocalizations). +# +# This cannot be done at upload time: the text is not part of the IPA, the +# archive, or the export. There is no -exportOptionsPlist key for it and altool +# has no flag for it. It has to be set after the upload, once App Store Connect +# has registered the build (usually a couple of minutes). +# +# Auth: the same credentials upload_ios.sh uses. ASC_KEY_ID and ASC_ISSUER_ID in +# the environment or ~/.meshmapper_release.env, with the .p8 at +# ~/.appstoreconnect/private_keys/AuthKey_.p8 unless ASC_KEY_PATH says +# otherwise. Set ASC_APP_ID in that file to skip the bundle-id lookup. +# +# Usage: +# ./set_whats_new.sh --notes-file notes.txt # build number from .last_build +# ./set_whats_new.sh --notes-file notes.txt --build-number 1787524245 +# echo "Fixed the map freezing on reconnect" | ./set_whats_new.sh +# ./set_whats_new.sh --notes-file notes.txt --dry-run # look it up, send nothing +# +# The text is always shown for confirmation before anything is sent. Pass --yes +# to skip that prompt (the release flow approves the draft before calling this). + +set -e + +cd "$(dirname "$0")" + +# ASC_API_BASE only exists so the flow can be exercised against a stub server +API_BASE="${ASC_API_BASE:-https://api.appstoreconnect.apple.com}" +BUNDLE_ID="${ASC_BUNDLE_ID:-net.meshmapper.app}" +MAX_WHATS_NEW=4000 + +NOTES_FILE="" +BUILD_NUMBER="" +LOCALE="" +ASSUME_YES=0 +DRY_RUN=0 +TIMEOUT=900 +POLL_INTERVAL=15 + +usage() { + echo "Usage: ./set_whats_new.sh [--notes-file PATH|-] [--build-number N] [--locale L]" + echo " [--yes] [--dry-run] [--timeout SECONDS]" + echo "" + echo " --notes-file File holding the What to Test text ('-' reads stdin)." + echo " Defaults to stdin when piped, else WhatToTest.txt." + echo " --build-number TestFlight build number. Defaults to EPOCH from .last_build." + echo " --locale App Store Connect locale. Defaults to the app's own" + echo " primary locale, which is what TestFlight falls back to." + echo " --yes Skip the confirmation prompt." + echo " --dry-run Resolve the app and build, print the text, send nothing." + echo " --timeout How long to wait for the build to register. Default 900s." +} + +while [ $# -gt 0 ]; do + case "$1" in + --notes-file) NOTES_FILE="$2"; shift 2 ;; + --build-number) BUILD_NUMBER="$2"; shift 2 ;; + --locale) LOCALE="$2"; shift 2 ;; + --timeout) TIMEOUT="$2"; shift 2 ;; + --yes|-y) ASSUME_YES=1; shift ;; + --dry-run) DRY_RUN=1; shift ;; + -h|--help) usage; exit 0 ;; + *) + echo "Error: Unknown argument: $1" + usage + exit 1 + ;; + esac +done + +# Local release secrets (optional, never committed; lives outside the repo) +RELEASE_ENV="$HOME/.meshmapper_release.env" +if [ -f "$RELEASE_ENV" ]; then + source "$RELEASE_ENV" +fi + +if [ -z "$ASC_KEY_ID" ] || [ -z "$ASC_ISSUER_ID" ]; then + echo "Error: ASC_KEY_ID and ASC_ISSUER_ID must be set (env or $RELEASE_ENV)." + exit 1 +fi + +ASC_KEY_PATH="${ASC_KEY_PATH:-$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8}" +if [ ! -f "$ASC_KEY_PATH" ]; then + echo "Error: API key file not found: $ASC_KEY_PATH" + exit 1 +fi + +for tool in curl jq python3 openssl; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "Error: $tool is required but not installed." + exit 1 + fi +done + +# --------------------------------------------------------------------------- +# Resolve the text +# --------------------------------------------------------------------------- + +has_content() { + printf '%s' "$1" | grep -q '[^[:space:]]' +} + +WHATS_NEW="" +if [ -n "$NOTES_FILE" ]; then + if [ "$NOTES_FILE" = "-" ]; then + WHATS_NEW=$(cat) + else + if [ ! -f "$NOTES_FILE" ]; then + echo "Error: Notes file not found: $NOTES_FILE" + exit 1 + fi + WHATS_NEW=$(cat "$NOTES_FILE") + fi +else + # Piped input wins, but an empty pipe (a run with stdin on /dev/null) falls + # through to WhatToTest.txt rather than looking like an empty note. + if [ ! -t 0 ]; then + WHATS_NEW=$(cat) + fi + if ! has_content "$WHATS_NEW" && [ -f WhatToTest.txt ]; then + WHATS_NEW=$(cat WhatToTest.txt) + fi +fi + +if ! has_content "$WHATS_NEW"; then + echo "Error: No What to Test text. Pass --notes-file PATH, pipe it in, or create WhatToTest.txt." + exit 1 +fi + +# Drop blank and whitespace-only lines from the top and bottom +WHATS_NEW=$(printf '%s\n' "$WHATS_NEW" | awk ' + { lines[NR] = $0 } + END { + start = 1 + end = NR + while (start <= end && lines[start] ~ /^[[:space:]]*$/) start++ + while (end >= start && lines[end] ~ /^[[:space:]]*$/) end-- + for (i = start; i <= end; i++) print lines[i] + }') + +# Count characters, not bytes: App Store Connect counts characters and release +# notes routinely carry non-ASCII +CHAR_COUNT=$(printf '%s' "$WHATS_NEW" | python3 -c 'import sys; sys.stdout.write(str(len(sys.stdin.read())))') +if [ "$CHAR_COUNT" -gt "$MAX_WHATS_NEW" ]; then + echo "Error: What to Test is $CHAR_COUNT characters. App Store Connect caps it at $MAX_WHATS_NEW." + exit 1 +fi + +# --------------------------------------------------------------------------- +# Resolve the build number +# --------------------------------------------------------------------------- + +if [ -z "$BUILD_NUMBER" ] && [ -f .last_build ]; then + BUILD_NUMBER=$(grep '^EPOCH=' .last_build | cut -d= -f2-) +fi +if [ -z "$BUILD_NUMBER" ]; then + echo "Error: No build number. Pass --build-number, or run ./Build.sh so .last_build exists." + exit 1 +fi + +# --------------------------------------------------------------------------- +# App Store Connect API +# --------------------------------------------------------------------------- + +# Mints a fresh ES256 JWT. Called per request so a long build-poll can never +# outlive the token (App Store Connect caps token lifetime at 20 minutes). +mint_jwt() { + python3 - "$ASC_KEY_PATH" "$ASC_KEY_ID" "$ASC_ISSUER_ID" <<'PY' +import base64 +import json +import subprocess +import sys +import time + +key_path, key_id, issuer_id = sys.argv[1], sys.argv[2], sys.argv[3] + + +def b64(raw): + return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii') + + +def compact(obj): + return json.dumps(obj, separators=(',', ':')).encode('utf-8') + + +now = int(time.time()) +header = {'alg': 'ES256', 'kid': key_id, 'typ': 'JWT'} +payload = {'iss': issuer_id, 'iat': now, 'exp': now + 900, 'aud': 'appstoreconnect-v1'} +signing_input = (b64(compact(header)) + '.' + b64(compact(payload))).encode('ascii') + +result = subprocess.run( + ['openssl', 'dgst', '-sha256', '-sign', key_path], + input=signing_input, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, +) +if result.returncode != 0: + sys.stderr.write('openssl could not sign with the API key:\n') + sys.stderr.write(result.stderr.decode('utf-8', 'replace')) + sys.exit(1) + +# openssl emits the ECDSA signature as DER (SEQUENCE of two INTEGERs). +# JWS ES256 wants the raw pair, each padded to the 32-byte P-256 field size. +der = result.stdout +if not der or der[0] != 0x30: + sys.stderr.write('Unexpected signature encoding from openssl.\n') + sys.exit(1) + +idx = 1 +length_byte = der[idx] +idx += 1 +if length_byte & 0x80: + idx += length_byte & 0x7F + +parts = [] +for _ in range(2): + if der[idx] != 0x02: + sys.stderr.write('Unexpected signature encoding from openssl.\n') + sys.exit(1) + idx += 1 + int_len = der[idx] + idx += 1 + parts.append(int.from_bytes(der[idx:idx + int_len], 'big')) + idx += int_len + +signature = parts[0].to_bytes(32, 'big') + parts[1].to_bytes(32, 'big') +sys.stdout.write(signing_input.decode('ascii') + '.' + b64(signature)) +PY +} + +HTTP_STATUS="" +HTTP_BODY="" + +# asc_request METHOD PATH [BODY_FILE] - fills HTTP_STATUS and HTTP_BODY +asc_request() { + local method="$1" + local path="$2" + local body_file="${3:-}" + local token + local response + + if ! token=$(mint_jwt); then + echo "Error: Could not mint an App Store Connect token." + exit 1 + fi + + # -g keeps curl from treating the filter[...] brackets as a glob range + if [ -n "$body_file" ]; then + response=$(curl -sS -g -X "$method" \ + -H "Authorization: Bearer $token" \ + -H "Content-Type: application/json" \ + --data-binary "@$body_file" \ + -w $'\n%{http_code}' \ + "${API_BASE}${path}") || return 1 + else + response=$(curl -sS -g -X "$method" \ + -H "Authorization: Bearer $token" \ + -w $'\n%{http_code}' \ + "${API_BASE}${path}") || return 1 + fi + + HTTP_STATUS="${response##*$'\n'}" + HTTP_BODY="${response%$'\n'*}" + return 0 +} + +api_error() { + local detail + detail=$(printf '%s' "$HTTP_BODY" | jq -r '.errors[]? | " [\(.status)] \(.code): \(.title) \(.detail // "")"' 2>/dev/null || true) + if [ -n "$detail" ]; then + printf '%s\n' "$detail" + else + printf ' %s\n' "$HTTP_BODY" + fi +} + +# --- App id --------------------------------------------------------------- + +APP_ID="$ASC_APP_ID" +if [ -z "$APP_ID" ]; then + echo "Looking up $BUNDLE_ID..." + if ! asc_request GET "/v1/apps?filter[bundleId]=${BUNDLE_ID}&limit=1"; then + echo "Error: Could not reach App Store Connect." + exit 1 + fi + if [ "$HTTP_STATUS" != "200" ]; then + echo "Error: App lookup failed (HTTP $HTTP_STATUS)." + api_error + exit 1 + fi + APP_ID=$(printf '%s' "$HTTP_BODY" | jq -r '.data[0].id // empty') + if [ -z "$APP_ID" ]; then + echo "Error: No app found for bundle id $BUNDLE_ID." + exit 1 + fi + echo " app id $APP_ID (set ASC_APP_ID=$APP_ID in $RELEASE_ENV to skip this lookup)" +fi + +# --- Locale --------------------------------------------------------------- +# Default to the app's own primary locale. App Store Connect pre-creates a +# localization in that locale on every build and TestFlight falls back to it, so +# writing only en-US on an en-CA app leaves most testers reading an empty What to +# Test while the text sits in a row they never see. + +if [ -z "$LOCALE" ]; then + if ! asc_request GET "/v1/apps/${APP_ID}"; then + echo "Error: Could not reach App Store Connect." + exit 1 + fi + if [ "$HTTP_STATUS" != "200" ]; then + echo "Error: Could not read the app's primary locale (HTTP $HTTP_STATUS)." + api_error + exit 1 + fi + LOCALE=$(printf '%s' "$HTTP_BODY" | jq -r '.data.attributes.primaryLocale // empty') + if [ -z "$LOCALE" ]; then + LOCALE="en-US" + echo " no primary locale reported, falling back to $LOCALE" + else + echo " app primary locale: $LOCALE" + fi +fi + +# --------------------------------------------------------------------------- +# Confirmation gate (everything below this point writes) +# --------------------------------------------------------------------------- + +echo "" +echo "============================================" +echo "TestFlight What to Test" +echo "App: $BUNDLE_ID" +echo "Build: $BUILD_NUMBER" +echo "Locale: $LOCALE" +echo "Length: $CHAR_COUNT / $MAX_WHATS_NEW characters" +echo "--------------------------------------------" +printf '%s\n' "$WHATS_NEW" +echo "============================================" +echo "" + +if [ "$ASSUME_YES" != "1" ] && [ "$DRY_RUN" != "1" ]; then + # Read the answer from the terminal, not stdin: stdin may have carried the note + if ! { exec 3< /dev/tty; } 2>/dev/null; then + echo "Error: No terminal to confirm on. Re-run with --yes to send without a prompt." + exit 1 + fi + printf "Send this to TestFlight? [y/N] " + read -r REPLY <&3 + exec 3<&- + case "$REPLY" in + [yY]|[yY][eE][sS]) ;; + *) echo "Aborted. Nothing was sent."; exit 1 ;; + esac + echo "" +fi + +# --- Build ---------------------------------------------------------------- +# filter[version] on /v1/builds is the build number (CFBundleVersion), which is +# the epoch Build.sh stamps in. The build shows up here a minute or two after the +# upload, well before processing finishes. + +BUILD_ID="" +ELAPSED=0 +echo "Finding build $BUILD_NUMBER..." +while true; do + if ! asc_request GET "/v1/builds?filter[app]=${APP_ID}&filter[version]=${BUILD_NUMBER}&sort=-uploadedDate&limit=1"; then + echo "Error: Could not reach App Store Connect." + exit 1 + fi + if [ "$HTTP_STATUS" != "200" ]; then + echo "Error: Build lookup failed (HTTP $HTTP_STATUS)." + api_error + exit 1 + fi + + BUILD_ID=$(printf '%s' "$HTTP_BODY" | jq -r '.data[0].id // empty') + if [ -n "$BUILD_ID" ]; then + BUILD_STATE=$(printf '%s' "$HTTP_BODY" | jq -r '.data[0].attributes.processingState // "UNKNOWN"') + echo " found build $BUILD_ID (processing state: $BUILD_STATE)" + break + fi + + if [ "$ELAPSED" -ge "$TIMEOUT" ]; then + echo "" + echo "Error: Build $BUILD_NUMBER has not appeared in App Store Connect after ${TIMEOUT}s." + echo "The upload may still be in flight. Re-run this script once it lands:" + echo " ./set_whats_new.sh --notes-file --build-number $BUILD_NUMBER" + exit 1 + fi + + echo " not registered yet, waiting (${ELAPSED}s elapsed)..." + sleep "$POLL_INTERVAL" + ELAPSED=$((ELAPSED + POLL_INTERVAL)) +done + +# --- Existing localization ------------------------------------------------ +# POSTing a locale that already exists returns 409, so an existing row is +# PATCHed instead. Xcode itself never creates one, but a re-run of this script +# does. + +if ! asc_request GET "/v1/builds/${BUILD_ID}/betaBuildLocalizations?limit=200"; then + echo "Error: Could not reach App Store Connect." + exit 1 +fi +if [ "$HTTP_STATUS" != "200" ]; then + echo "Error: Localization lookup failed (HTTP $HTTP_STATUS)." + api_error + exit 1 +fi +LOCALIZATION_ID=$(printf '%s' "$HTTP_BODY" | jq -r --arg locale "$LOCALE" '.data[]? | select(.attributes.locale == $locale) | .id' | head -1) + +if [ "$DRY_RUN" = "1" ]; then + echo "" + echo "============================================" + echo "DRY RUN - nothing was sent" + echo "App id: $APP_ID" + echo "Build id: $BUILD_ID (build number $BUILD_NUMBER)" + if [ -n "$LOCALIZATION_ID" ]; then + echo "Would PATCH existing $LOCALE localization $LOCALIZATION_ID" + else + echo "Would POST a new $LOCALE localization" + fi + echo "============================================" + exit 0 +fi + +# --- Write ---------------------------------------------------------------- + +BODY_FILE=$(mktemp -t meshmapper_whatsnew) +trap 'rm -f "$BODY_FILE"' EXIT + +if [ -n "$LOCALIZATION_ID" ]; then + echo "Updating the existing $LOCALE What to Test..." + jq -n --arg id "$LOCALIZATION_ID" --arg whatsNew "$WHATS_NEW" \ + '{data: {id: $id, type: "betaBuildLocalizations", attributes: {whatsNew: $whatsNew}}}' > "$BODY_FILE" + if ! asc_request PATCH "/v1/betaBuildLocalizations/${LOCALIZATION_ID}" "$BODY_FILE"; then + echo "Error: Could not reach App Store Connect." + exit 1 + fi + EXPECTED_STATUS="200" +else + echo "Setting the $LOCALE What to Test..." + jq -n --arg locale "$LOCALE" --arg whatsNew "$WHATS_NEW" --arg build "$BUILD_ID" \ + '{data: {type: "betaBuildLocalizations", attributes: {locale: $locale, whatsNew: $whatsNew}, relationships: {build: {data: {type: "builds", id: $build}}}}}' > "$BODY_FILE" + if ! asc_request POST "/v1/betaBuildLocalizations" "$BODY_FILE"; then + echo "Error: Could not reach App Store Connect." + exit 1 + fi + EXPECTED_STATUS="201" +fi + +if [ "$HTTP_STATUS" != "$EXPECTED_STATUS" ]; then + echo "Error: App Store Connect rejected the What to Test text (HTTP $HTTP_STATUS)." + api_error + exit 1 +fi + +echo "" +echo "What to Test set on build $BUILD_NUMBER ($LOCALE)." +echo "Testers see it in TestFlight once the build finishes processing." diff --git a/test/screens/watch_diagnostics_screen_test.dart b/test/screens/watch_diagnostics_screen_test.dart new file mode 100644 index 00000000..a0d3597b --- /dev/null +++ b/test/screens/watch_diagnostics_screen_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/screens/watch_diagnostics_screen.dart'; +import 'package:mesh_mapper/services/watch/watch_bridge_service.dart'; + +/// Renders the diagnostic in the exact shape of the 2026-08-14 incident: +/// paired and reachable, but `installed` false, which silently closed the +/// sync gate for two hours with no log line anywhere. +void main() { + testWidgets('names the condition that closed the gate', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const channel = MethodChannel('meshmapper/watch_diag_test'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'status') { + return { + 'supported': true, + 'activated': true, + 'paired': true, + 'installed': false, + 'reachable': true, + }; + } + return null; + }); + final bridge = WatchBridgeService(channel: channel); + + await tester.pumpWidget( + MaterialApp(home: WatchDiagnosticsScreen(bridge: bridge)), + ); + await tester.pump(); + + expect(find.text('Failing condition: installed'), findsOneWidget); + expect(find.text('installed'), findsOneWidget); + expect(find.text('canSync'), findsOneWidget); + + await tester.pumpWidget(const SizedBox()); + bridge.dispose(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + debugDefaultTargetPlatformOverride = null; + }); +} diff --git a/test/services/api_queue_offline_retry_test.dart b/test/services/api_queue_offline_retry_test.dart new file mode 100644 index 00000000..8c134355 --- /dev/null +++ b/test/services/api_queue_offline_retry_test.dart @@ -0,0 +1,162 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mesh_mapper/services/api_queue_service.dart'; +import 'package:mesh_mapper/services/api_service.dart'; + +/// Regression tests for #437: driving without cell coverage loses samples. +/// +/// A phone with no signal fails the POST at the socket, so the queue can't +/// tell "the server rejected this" from "I couldn't reach the server". Both +/// come back as UploadResult.retryable and both burn a slot on the 5-attempt +/// retry ladder. Once an item hits _maxRetries it is filtered out of every +/// future batch, nothing ever resets the counter, and `failedItems` (the only +/// thing that can still see it) has no callers, so the ping is stranded in +/// Hive for good. +void main() { + /// An ApiService holding a live session whose /wardrive posts all fail the + /// way an out-of-coverage phone fails: the socket never connects. + Future<({ApiService api, List wardrivePosts})> offlineApi() async { + final wardrivePosts = []; + final api = ApiService(client: MockClient((request) async { + if (request.url.path.endsWith('/auth')) { + return http.Response( + json.encode({ + 'success': true, + 'session_id': 'yow-20260824-0001', + 'tx_allowed': true, + 'rx_allowed': true, + 'expires_at': DateTime.now() + .add(const Duration(hours: 1)) + .toUtc() + .toIso8601String(), + }), + 200, + ); + } + wardrivePosts.add(request.url.path); + throw const SocketException('Network is unreachable'); + })); + + await api.requestAuth( + reason: 'connect', + publicKey: 'ab' * 32, + lat: 45.26974, + lon: -75.77746, + accuracyMeters: 5, + ); + + return (api: api, wardrivePosts: wardrivePosts); + } + + Future enqueueRx(ApiQueueService queue) => queue.enqueueRx( + latitude: 45.26974, + longitude: -75.77746, + heardRepeats: '4e(12.25)', + timestamp: 1750000000, + repeaterId: '4e', + externalAntenna: false, + ); + + test('an unreachable network must not consume the retry ladder', () async { + final harness = await offlineApi(); + // No init(), so Hive stays closed and the service uses its in-memory + // queue. Same enqueue/upload code paths, no Hive box needed. + final queue = ApiQueueService(apiService: harness.api); + await enqueueRx(queue); + + await queue.flushQueue(); + await queue.flushQueue(); + + expect( + harness.wardrivePosts, + hasLength(2), + reason: 'both flushes should have tried the network. If the second was ' + 'skipped, the first failure started the backoff ladder, which means ' + 'a dead socket is being counted as a server rejection', + ); + }); + + test('a ping is stranded by a 30 second outage', () async { + final harness = await offlineApi(); + final queue = ApiQueueService(apiService: harness.api); + await enqueueRx(queue); + + // Walk the real backoff ladder: 1s, 2s, 4s, 8s, 16s. Real time, because + // isReadyForRetry reads DateTime.now() directly. + for (var i = 0; i < 6; i++) { + await queue.flushQueue(); + // Wait out the backoff a failed attempt would have armed, so the next + // flush is a real attempt. 1s, 2s, 4s, 8s, 16s is the whole ladder. + if (i < 5) await Future.delayed(Duration(seconds: 1 << i)); + } + + expect(queue.queueSize, 1, reason: 'the sample is still held in the queue'); + expect( + queue.failedItems, + isEmpty, + reason: 'nothing in the app ever reads failedItems or resets retryCount, ' + 'so an item that lands here is coverage the user will never get', + ); + }, timeout: const Timeout(Duration(minutes: 2))); + + test('coming back online revives what the ladder wrote off', () async { + // An item can still exhaust the ladder against a server that is reachable + // but keeps rejecting: five 500s in a row. Nothing resets the counter, so + // without a revive it would sit in Hive forever (#437). + var reachable = false; + final api = ApiService(client: MockClient((request) async { + if (request.url.path.endsWith('/auth')) { + return http.Response( + json.encode({ + 'success': true, + 'session_id': 'yow-20260824-0002', + 'tx_allowed': true, + 'rx_allowed': true, + }), + 200, + ); + } + return reachable + ? http.Response(json.encode({'success': true}), 200) + : http.Response('upstream is having a moment', 500); + })); + await api.requestAuth( + reason: 'connect', + publicKey: 'ab' * 32, + lat: 45.26974, + lon: -75.77746, + accuracyMeters: 5, + ); + + final queue = ApiQueueService(apiService: api); + await enqueueRx(queue); + // RX pings sit in the per-repeater buffer until a flush moves them into the + // queue proper, so this first pass is what makes the item visible at all. + await queue.flushQueue(); + + // Strand it. Set the retry state directly rather than spending 31 seconds + // of real time climbing the ladder. + for (final item in queue.heldItems) { + item.retryCount = 5; + item.lastRetryAt = null; + } + expect(queue.failedItems, hasLength(1), + reason: 'precondition: the item is past the ladder'); + + // A second ping arrives and the server is answering again. + reachable = true; + await enqueueRx(queue); + await queue.flushQueue(); + + expect(queue.failedItems, isEmpty, + reason: 'a successful upload proves the server is reachable, so the ' + 'written-off item goes back in the running'); + await queue.flushQueue(); + expect(queue.queueSize, 0, + reason: 'and on the next pass it actually uploads'); + }); +} diff --git a/test/services/api_queue_wire_tag_test.dart b/test/services/api_queue_wire_tag_test.dart new file mode 100644 index 00000000..e02f39ef --- /dev/null +++ b/test/services/api_queue_wire_tag_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:mesh_mapper/services/api_queue_service.dart'; +import 'package:mesh_mapper/services/api_service.dart'; + +/// Regression tests for the silently-dropped TX pings behind server issues +/// #108 and #123. +/// +/// A TX wire tag is `wireTagEncode(session_id, ping_counter, key)`. The server +/// recomputes it from the session the batch is POSTed under and skips the entry +/// outright on a mismatch (`wardrive-api.php`, action=wire_tag_mismatch) while +/// still returning success, so the app prunes the item as uploaded and the +/// ping is gone with no error anywhere. +/// +/// That means a tag is only ever valid under the session that minted it. Two +/// app paths carried a tag across a session boundary; prod access.log for +/// 2026-08-23 shows 102 of 111 mismatches on `offline-*` sessions (the first +/// case) and 9 on live ones (the second). +/// +/// Such an item is now DROPPED, not un-tagged. It is ~0.2% of TX pings, and +/// every alternative is worse: keeping the tag is a silent loss plus a warn, +/// and stripping it sends the ping down the server coords path where, with no +/// status-4 WAIT row left to join, it inserts as DEAD(3) and paints a grey +/// "dead" cell on the map for a ping that was actually heard. +void main() { + ApiQueueService newQueue() => + // No init(), so Hive stays closed and _safeWrite falls back to the + // in-memory queue. That exercises the same enqueue/extract code paths + // without needing a Hive box in the test harness. + ApiQueueService(apiService: ApiService()); + + Future enqueueTaggedTx(ApiQueueService queue) => queue.enqueueTx( + latitude: 45.26974, + longitude: -75.77746, + heardRepeats: '4e(12.25)', + timestamp: 1750000000, + externalAntenna: false, + pingCounter: 13, + wireTag: 'MM:YVNPAr5OIw', + ); + + Future enqueueRx(ApiQueueService queue) => queue.enqueueRx( + latitude: 45.26974, + longitude: -75.77746, + heardRepeats: '4e(12.25)', + timestamp: 1750000000, + repeaterId: '4e', + externalAntenna: false, + ); + + group('tagged pings never outlive the session that minted them', () { + test('offline preservation drops them (#108, 92% of prod mismatches)', + () async { + final queue = newQueue(); + await enqueueTaggedTx(queue); + await enqueueRx(queue); + + // handleSessionError() snapshots the queue here and hands it to + // OfflineSessionService, which re-uploads it later under a brand new + // `offline-YYYYMMDD-NNNN` session id. + final preserved = await queue.extractAllAsJson(); + + expect(preserved, hasLength(1), + reason: 'the tagged TX ping is undeliverable under any offline ' + 'session, so it must not be preserved at all'); + expect(preserved.single['type'], 'RX', + reason: 'untagged observations still preserve exactly as before'); + expect(preserved.any((p) => p['wire_tag'] != null), isFalse); + expect(preserved.any((p) => p['ping_counter'] != null), isFalse); + }); + + test('a new session id drops what is already queued (#123, live sessions)', + () async { + final queue = newQueue(); + await enqueueTaggedTx(queue); + await enqueueRx(queue); + // RX sits in the per-repeater buffer until a flush, so the main queue + // holds exactly the tagged TX ping at this point. + expect(queue.queueSize, 1); + + // Auto-reconnect deliberately preserves the queue, but /auth only reuses + // a session while it is status=1 and unexpired. Otherwise a new + // session_id comes back and everything already queued is stale. + await queue.dropStaleTaggedItems(); + + // Assert on queueSize, NOT on extractAllAsJson: the snapshot applies the + // same tag filter itself, so checking only its output passes even when + // dropStaleTaggedItems drops nothing at all. + expect(queue.queueSize, 0, + reason: 'the tagged TX ping must actually leave the queue'); + + final pending = await queue.extractAllAsJson(); + expect(pending, hasLength(1)); + expect(pending.single['type'], 'RX', + reason: 'RX carries no tag, so a new session does not invalidate it'); + }); + + test('an untagged queue is left completely alone', () async { + final queue = newQueue(); + await enqueueRx(queue); + + await queue.dropStaleTaggedItems(); + + final pending = await queue.extractAllAsJson(); + expect(pending, hasLength(1)); + expect(pending.single['type'], 'RX'); + expect(pending.single['heard_repeats'], '4e(12.25)'); + }); + }); +} diff --git a/test/services/api_service_coverage_test.dart b/test/services/api_service_coverage_test.dart new file mode 100644 index 00000000..4ce64dfd --- /dev/null +++ b/test/services/api_service_coverage_test.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mesh_mapper/services/api_service.dart'; + +void main() { + ApiService serviceAnswering(http.Response Function(http.Request) responder) => + ApiService(client: MockClient((request) async => responder(request))); + + group('fetchRepeaterCoverage separates "nothing" from "could not ask"', () { + test('points come back as points', () async { + final api = serviceAnswering((_) => http.Response( + json.encode([ + {'status': 1, 'lat': 45.0, 'lon': -75.0}, + ]), + 200, + )); + + final points = await api.fetchRepeaterCoverage(zone: 'yow', prefix: 'ab'); + + expect(points, isNotNull); + expect(points!.length, 1); + }); + + test('an empty list is a real answer, not a failure', () async { + final api = serviceAnswering((_) => http.Response('[]', 200)); + + final points = await api.fetchRepeaterCoverage(zone: 'yow', prefix: 'ab'); + + expect(points, isNotNull, reason: 'the server answered: it has nothing'); + expect(points, isEmpty); + }); + + test('an HTTP error is "could not ask"', () async { + final api = serviceAnswering((_) => http.Response('nope', 500)); + + expect( + await api.fetchRepeaterCoverage(zone: 'yow', prefix: 'ab'), isNull); + }); + + test('a body that is not a JSON list is "could not ask"', () async { + final api = + serviceAnswering((_) => http.Response('{"error":"boom"}', 200)); + + expect( + await api.fetchRepeaterCoverage(zone: 'yow', prefix: 'ab'), isNull); + }); + + test('a transport failure is "could not ask"', () async { + final api = ApiService( + client: MockClient((_) async => throw http.ClientException('down'))); + + expect( + await api.fetchRepeaterCoverage(zone: 'yow', prefix: 'ab'), isNull); + }); + }); + + group('fetchMapData still collapses failure to empty', () { + // The cell summary has no "couldn't load" state (#109 is the repeater + // sheet only), and its tap flow chains straight into filterWithinBlob. + test('an HTTP error yields an empty list, never null', () async { + final api = serviceAnswering((_) => http.Response('nope', 500)); + + final points = await api.fetchMapData( + zone: 'yow', lat: 45.0, lon: -75.0, radiusMeters: 300); + + expect(points, isEmpty); + }); + + test('points still come back as points', () async { + final api = serviceAnswering((_) => http.Response( + json.encode([ + {'status': 1, 'lat': 45.0, 'lon': -75.0}, + ]), + 200, + )); + + final points = await api.fetchMapData( + zone: 'yow', lat: 45.0, lon: -75.0, radiusMeters: 300); + + expect(points.length, 1); + }); + }); +} diff --git a/test/services/api_service_heartbeat_test.dart b/test/services/api_service_heartbeat_test.dart new file mode 100644 index 00000000..5f11ed13 --- /dev/null +++ b/test/services/api_service_heartbeat_test.dart @@ -0,0 +1,144 @@ +import 'dart:convert'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mesh_mapper/services/api_service.dart'; + +/// Regression tests for the 2026-08-29 heartbeat POST storm (361k requests in +/// 64 minutes from one device). +/// +/// The trigger: expires_at comes from the SERVER clock while the scheduling +/// math runs on the DEVICE clock. A device clock running further ahead of the +/// server than TTL minus the 1-minute buffer (4+ minutes at the server's 300s +/// TTL) makes every freshly-extended expiry look already due, and the old +/// "expired, send immediately" path re-fired the next heartbeat straight from +/// the success response with no minimum interval. scheduleHeartbeat() also +/// cancels only timers, never an in-flight send, so every upload success and +/// per-ping session check stacked another self-sustaining chain. +void main() { + /// Builds an ApiService whose mock server always answers success with the + /// given expires_at, counting heartbeat POSTs. After [maxHeartbeats] the + /// server kills the session so a hot-looping client cannot hang the test. + ({ApiService api, List heartbeats}) build({ + required int Function() expiresAt, + int maxHeartbeats = 100, + }) { + final heartbeats = []; + final api = ApiService( + client: MockClient((request) async { + final body = json.decode(request.body) as Map; + if (request.url.path.endsWith('/auth')) { + return http.Response( + json.encode({ + 'success': true, + 'session_id': 'YOW-20260830-0001', + 'tx_allowed': true, + 'rx_allowed': true, + 'expires_at': expiresAt(), + }), + 200, + ); + } + if (body['heartbeat'] == true) { + heartbeats.add(DateTime.now()); + if (heartbeats.length >= maxHeartbeats) { + return http.Response( + json.encode({ + 'success': false, + 'reason': 'session_expired', + 'message': 'killed by test backstop', + }), + 401, + ); + } + return http.Response( + json.encode({'success': true, 'expires_at': expiresAt()}), + 200, + ); + } + return http.Response( + json.encode({'success': true, 'expires_at': expiresAt()}), + 200, + ); + }), + ); + return (api: api, heartbeats: heartbeats); + } + + void connect(FakeAsync async, ApiService api) { + api.requestAuth( + reason: 'connect', + publicKey: 'AB', + lat: 45.0, + lon: -75.0, + ); + async.flushMicrotasks(); + } + + test('a stale expires_at never hot-loops heartbeats on success', () { + fakeAsync((async) { + // Device clock "ahead": the server's answer always looks expired. + final built = build( + expiresAt: () => + DateTime.now().millisecondsSinceEpoch ~/ 1000 - 100); + connect(async, built.api); + + built.api.enableHeartbeat(); + async.flushMicrotasks(); + async.elapse(const Duration(minutes: 5)); + + // One immediate catch-up send, then paced at the spacing floor: at a + // 30s floor that is at most 11 in 5 minutes. The unfixed loop reaches + // the 100-send backstop within the first flush. + expect(built.heartbeats.length, inInclusiveRange(1, 12), + reason: 'heartbeats must be paced by a minimum interval even when ' + 'expires_at always reads as already expired'); + }); + }); + + test('repeated scheduleHeartbeat calls do not stack concurrent chains', () { + fakeAsync((async) { + final built = build( + expiresAt: () => + DateTime.now().millisecondsSinceEpoch ~/ 1000 - 100); + connect(async, built.api); + + built.api.enableHeartbeat(); + async.flushMicrotasks(); + // Simulate what the storm did: every upload success and per-ping + // session check re-enters scheduleHeartbeat while state reads expired. + for (var i = 0; i < 5; i++) { + built.api.scheduleHeartbeat( + DateTime.now().millisecondsSinceEpoch ~/ 1000 - 100); + async.flushMicrotasks(); + } + async.elapse(const Duration(minutes: 2)); + + expect(built.heartbeats.length, inInclusiveRange(1, 6), + reason: 're-entrant scheduling must coalesce into one paced chain, ' + 'not one chain per caller'); + }); + }); + + test('a healthy expiry still schedules the normal keepalive', () { + fakeAsync((async) { + final built = build( + expiresAt: () => + DateTime.now().millisecondsSinceEpoch ~/ 1000 + 300); + connect(async, built.api); + + built.api.enableHeartbeat(); + async.flushMicrotasks(); + + async.elapse(const Duration(seconds: 230)); + expect(built.heartbeats, isEmpty, + reason: 'keepalive fires 1 minute before expiry, not earlier'); + + async.elapse(const Duration(seconds: 20)); + expect(built.heartbeats.length, 1, + reason: 'the ordinary pre-expiry keepalive must still go out'); + }); + }); +} diff --git a/test/services/app_intents/app_intent_bridge_service_test.dart b/test/services/app_intents/app_intent_bridge_service_test.dart new file mode 100644 index 00000000..a5a25e3d --- /dev/null +++ b/test/services/app_intents/app_intent_bridge_service_test.dart @@ -0,0 +1,311 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/app_intents/app_intent_bridge_service.dart'; +import 'package:mesh_mapper/services/app_intents/app_intent_commands.dart'; +import 'package:mesh_mapper/services/external_commands/external_command_models.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('AppIntentBridgeService', () { + late MethodChannel channel; + late AppIntentBridgeService bridge; + late List> published; + + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + channel = const MethodChannel('meshmapper/app_intents_test'); + published = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'publishSnapshot') { + published.add(call.arguments as Map); + } + return null; + }); + bridge = AppIntentBridgeService( + channel: channel, + debounceDelay: const Duration(milliseconds: 5), + minimumNonUrgentInterval: const Duration(milliseconds: 25), + ); + }); + + tearDown(() { + bridge.dispose(); + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + Future> sendCommand( + String id, { + String source = 'siri', + String kind = 'startSession', + String? mode = 'passive', + Object? expiresAtMs, + }) async { + final reply = await TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(MethodCall('command', { + 'id': id, + 'source': source, + 'kind': kind, + 'issuedAtMs': DateTime.now().millisecondsSinceEpoch, + if (expiresAtMs != null) 'expiresAtMs': expiresAtMs, + if (mode != null) 'mode': mode, + })), + null, + ); + return channel.codec.decodeEnvelope(reply!) as Map; + } + + test('native mutation waits for completion and deduplicates its UUID', + () async { + var calls = 0; + final released = Completer(); + bridge.attachCommandHandler((command) async { + calls++; + await released.future; + return const ExternalCommandCompletion( + success: true, + disposition: ExternalCommandDisposition.admitted, + message: ExternalCommandReason.other( + 'MeshMapper started in Passive Discovery mode.', + ), + sessionId: 'session-1', + mode: ExternalSessionMode.passive, + ); + }); + + final pending = sendCommand('command-1'); + await Future.delayed(Duration.zero); + expect(calls, 1); + var completed = false; + unawaited(pending.then((_) => completed = true)); + await Future.delayed(Duration.zero); + expect(completed, isFalse); + + released.complete(); + final first = await pending; + final duplicate = await sendCommand('command-1'); + + expect(first['success'], isTrue); + expect(first['disposition'], 'admitted'); + expect(first['sessionId'], 'session-1'); + expect(duplicate, first); + expect(calls, 1); + }); + + test('concurrent duplicate mutations share one in-flight execution', + () async { + var calls = 0; + final released = Completer(); + bridge.attachCommandHandler((command) async { + calls++; + await released.future; + return const ExternalCommandCompletion( + success: true, + disposition: ExternalCommandDisposition.admitted, + message: ExternalCommandReason.other('Connected'), + ); + }); + + final first = sendCommand('command-concurrent'); + final duplicate = sendCommand('command-concurrent'); + await Future.delayed(Duration.zero); + expect(calls, 1); + + released.complete(); + expect(await duplicate, await first); + expect(calls, 1); + }); + + test('concurrent duplicates share the same handler failure refusal', + () async { + var calls = 0; + final released = Completer(); + bridge.attachCommandHandler((command) async { + calls++; + await released.future; + throw StateError('failed once'); + }); + + final first = sendCommand('command-concurrent-failure'); + final duplicate = sendCommand('command-concurrent-failure'); + await Future.delayed(Duration.zero); + expect(calls, 1); + + released.complete(); + expect(await duplicate, await first); + expect(await first, containsPair('message', 'Command failed')); + expect(calls, 1); + }); + + test('wrong-source and malformed commands fail closed', () async { + bridge.attachCommandHandler((_) async => throw StateError('not called')); + + final result = await sendCommand('command-2', source: 'watch'); + + expect(result['success'], isFalse); + expect(result['disposition'], 'refused'); + expect(result['message'], 'Malformed command'); + }); + + test("the intent's deadline reaches the shared session command", () async { + final deadline = DateTime.now().add(const Duration(seconds: 10)); + AppIntentCommand? received; + bridge.attachCommandHandler((command) async { + received = command; + return const ExternalCommandCompletion( + success: true, + disposition: ExternalCommandDisposition.admitted, + ); + }); + + await sendCommand( + 'command-deadline', + expiresAtMs: deadline.millisecondsSinceEpoch, + ); + + expect( + received?.expiresAt?.millisecondsSinceEpoch, + deadline.millisecondsSinceEpoch, + ); + expect( + received?.toExternalSessionCommand()?.expiresAt, + received?.expiresAt, + ); + }); + + test('an older native build sending no deadline still runs', () async { + AppIntentCommand? received; + bridge.attachCommandHandler((command) async { + received = command; + return const ExternalCommandCompletion( + success: true, + disposition: ExternalCommandDisposition.admitted, + ); + }); + + final result = await sendCommand('command-no-deadline'); + + expect(received?.expiresAt, isNull); + expect(result['success'], isTrue); + }); + + test('a non-numeric deadline fails closed', () async { + bridge.attachCommandHandler((_) async => throw StateError('not called')); + + final result = await sendCommand( + 'command-bad-deadline', + expiresAtMs: 'soon', + ); + + expect(result['disposition'], 'refused'); + expect(result['message'], 'Malformed command'); + }); + + test('connect-last-companion remains a Siri-only mutation', () async { + AppIntentCommand? received; + bridge.attachCommandHandler((command) async { + received = command; + return const ExternalCommandCompletion( + success: true, + disposition: ExternalCommandDisposition.admitted, + message: ExternalCommandReason.other( + 'MeshMapper connected to Test Radio.', + ), + ); + }); + + final result = await sendCommand( + 'command-connect', + kind: 'connectLastCompanion', + mode: null, + ); + + expect(received?.kind, AppIntentCommandKind.connectLastCompanion); + expect(received?.toExternalSessionCommand(), isNull); + expect(result['success'], isTrue); + expect(result['message'], 'MeshMapper connected to Test Radio.'); + }); + + test('semantic duplicate snapshots ignore updatedAtMs', () async { + Map snapshot(int updatedAtMs, {bool active = false}) => { + 'version': 1, + 'updatedAtMs': updatedAtMs, + 'session': {'active': active}, + }; + + var builds = 0; + Map build(int updatedAtMs, {bool active = false}) { + builds++; + return snapshot(updatedAtMs, active: active); + } + + bridge.schedule( + () => build(1), + preflightKeyBuilder: () => 'idle', + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 10)); + bridge.schedule( + () => build(2), + preflightKeyBuilder: () => 'idle', + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 10)); + bridge.schedule( + () => build(3, active: true), + preflightKeyBuilder: () => 'active', + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(builds, 2, + reason: 'an unchanged preflight must skip the expensive builder'); + expect(published, hasLength(2)); + expect((published.last['session'] as Map)['active'], isTrue); + }); + + test('preflight dedupe starts only after a snapshot is published', + () async { + var attempts = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method != 'publishSnapshot') return null; + attempts++; + if (attempts == 1) { + throw PlatformException(code: 'not-written'); + } + published.add(call.arguments as Map); + return null; + }); + + var builds = 0; + Map build() { + builds++; + return {'version': 1, 'updatedAtMs': builds}; + } + + for (var delivery = 0; delivery < 3; delivery++) { + bridge.schedule( + build, + preflightKeyBuilder: () => 'unchanged', + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 10)); + } + + expect(attempts, 2); + expect(builds, 2, + reason: 'a refused first publish must not arm preflight dedupe'); + expect(published, hasLength(1)); + }); + }); +} diff --git a/test/services/app_intents/last_companion_connection_test.dart b/test/services/app_intents/last_companion_connection_test.dart new file mode 100644 index 00000000..eb15c32b --- /dev/null +++ b/test/services/app_intents/last_companion_connection_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/app_intents/app_intent_commands.dart'; +import 'package:mesh_mapper/services/app_intents/last_companion_connection.dart'; +import 'package:mesh_mapper/services/external_commands/external_command_models.dart'; +import 'package:mesh_mapper/services/external_commands/external_session_commands.dart'; + +void main() { + final now = DateTime.fromMillisecondsSinceEpoch(100000); + + AppIntentCommand command({DateTime? issuedAt, DateTime? expiresAt}) => + AppIntentCommand( + id: 'connect-1', + kind: AppIntentCommandKind.connectLastCompanion, + issuedAt: issuedAt ?? now, + expiresAt: expiresAt, + ); + + ExternalCommandAdmission resolve({ + bool remembered = true, + bool connected = false, + bool connectedToRemembered = false, + bool connecting = false, + bool unattended = true, + DateTime? issuedAt, + DateTime? expiresAt, + }) => + resolveLastCompanionConnection( + command: command(issuedAt: issuedAt, expiresAt: expiresAt), + hasRememberedCompanion: remembered, + isConnected: connected, + isConnectedToRememberedCompanion: connectedToRemembered, + isConnecting: connecting, + canReconnectWithoutUserInput: unattended, + now: now, + ); + + test('admits an unattended reconnect to the remembered companion', () { + expect(resolve().disposition, ExternalCommandDisposition.admitted); + }); + + test('already connected to the remembered companion is a no-op', () { + final result = resolve( + connected: true, + connectedToRemembered: true, + ); + + expect(result.disposition, ExternalCommandDisposition.noOp); + expect(result.reason?.compactText, 'Already connected'); + }); + + test('fails closed for missing, busy, switched, and USB companions', () { + expect( + resolve(remembered: false).reason?.compactText, + 'No remembered companion', + ); + expect( + resolve(connecting: true).reason?.compactText, + 'Already connecting', + ); + expect( + resolve(connected: true).reason?.compactText, + 'Another companion is connected', + ); + expect( + resolve(unattended: false).reason?.compactText, + 'User interaction required', + ); + }); + + test('stale and future connect commands are refused', () { + expect( + resolve(issuedAt: now.subtract(const Duration(seconds: 31))) + .reason + ?.compactText, + externalCommandExpiredReason, + ); + expect( + resolve(issuedAt: now.add(const Duration(seconds: 6))) + .reason + ?.compactText, + externalCommandExpiredReason, + ); + }); + + group("Connect honours the intent's deadline", () { + test('a passed deadline is refused even while the command is young', () { + // Connect's deadline is set when the command reaches Dart, so it can pass + // well before the shared 30-second age rule would refuse the command. + final admission = resolve( + expiresAt: now.subtract(const Duration(seconds: 1)), + ); + + expect(admission.disposition, ExternalCommandDisposition.refused); + expect(admission.reason?.compactText, externalCommandExpiredReason); + }); + + test('a live deadline still admits', () { + expect( + resolve(expiresAt: now.add(const Duration(seconds: 20))).disposition, + ExternalCommandDisposition.admitted, + ); + }); + + test('the deadline is checked before every other refusal', () { + // Expiry is about whether anyone is still listening, so it outranks the + // state-based refusals; reporting "already connecting" to a caller that + // has gone would be answering a question nobody asked. + final admission = resolve( + connecting: true, + expiresAt: now.subtract(const Duration(seconds: 1)), + ); + + expect(admission.reason?.compactText, externalCommandExpiredReason); + }); + + test('an older native build sending no deadline is unaffected', () { + expect(resolve().disposition, ExternalCommandDisposition.admitted); + }); + }); +} diff --git a/test/services/app_intents/siri_snapshot_builder_test.dart b/test/services/app_intents/siri_snapshot_builder_test.dart new file mode 100644 index 00000000..cbf56680 --- /dev/null +++ b/test/services/app_intents/siri_snapshot_builder_test.dart @@ -0,0 +1,711 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/models/log_entry.dart'; +import 'package:mesh_mapper/models/repeater.dart'; +import 'package:mesh_mapper/services/app_intents/siri_snapshot_builder.dart'; +import 'package:mesh_mapper/services/app_intents/siri_snapshot_models.dart'; +import 'package:mesh_mapper/services/watch/watch_models.dart'; + +void main() { + final now = DateTime.fromMillisecondsSinceEpoch(1787535920000); + const capitolHill = Repeater( + id: 'database-123', + hexId: '4E5D82AA', + name: 'Capitol Hill', + lat: 47.6202, + lon: -122.3194, + lastHeard: 1787535900, + enabled: 1, + iata: 'SEA', + ); + + SiriRecentHeard buildHeard({ + List tx = const [], + List rx = const [], + List discovery = const [], + List trace = const [], + List repeaters = const [capitolHill], + }) => + SiriSnapshotBuilder.buildRecentHeard( + txEntries: tx, + rxEntries: rx, + discoveryEntries: discovery, + traceEntries: trace, + repeaters: repeaters, + hopBytes: 2, + now: now, + ); + + List build({ + List tx = const [], + List rx = const [], + List discovery = const [], + List trace = const [], + List repeaters = const [capitolHill], + }) => + buildHeard( + tx: tx, + rx: rx, + discovery: discovery, + trace: trace, + repeaters: repeaters, + ).observations; + + test('direct TX resolves only an unambiguous identity and uses event GPS', + () { + final at = now.subtract(const Duration(minutes: 1)); + final observations = build(tx: [ + TxLogEntry( + timestamp: at, + latitude: 47.6000, + longitude: -122.3000, + power: 1, + events: [RxEvent(repeaterId: '4E5D', snr: 8.5, rssi: -91)], + ), + ]); + + expect(observations, hasLength(1)); + final heard = observations.single; + expect(heard.name, 'Capitol Hill'); + expect(heard.entityId, 'SEA|database-123'); + expect(heard.direct, isTrue); + expect( + heard.distanceM, + closeTo( + WatchWire.distanceMeters( + 47.6000, + -122.3000, + capitolHill.lat, + capitolHill.lon, + ), + 0.01, + )); + }); + + test('ambiguous short hashes stay unnamed', () { + const collision = Repeater( + id: 'database-456', + hexId: '4E5D99BB', + name: 'Collision', + lat: 47.7, + lon: -122.4, + lastHeard: 1787535900, + enabled: 1, + iata: 'SEA', + ); + final observations = build( + tx: [ + TxLogEntry( + timestamp: now, + latitude: 47.6, + longitude: -122.3, + power: 1, + events: [RxEvent(repeaterId: '4E5D', snr: 7.2)], + ), + ], + repeaters: const [capitolHill, collision], + ); + + expect(observations.single.resolved, isFalse); + expect(observations.single.name, isNull); + expect(observations.single.entityId, isNull); + }); + + test('multi-hop TX preserves route semantics and signal values', () { + final observations = build(tx: [ + TxLogEntry( + timestamp: now, + latitude: 47.6, + longitude: -122.3, + power: 1, + events: const [], + multiHopEvents: [ + MultiHopEchoEvent( + repeaterId: '4E5D82AA', + snr: 3.25, + rssi: -102, + pathHops: const ['AA', 'BB', '4E5D82AA'], + ), + ], + ), + ]); + + final heard = observations.single; + expect(heard.kind, SiriObservationKind.txEcho); + expect(heard.direct, isFalse); + expect(heard.hopCount, 3); + expect(heard.snr, 3.25); + expect(heard.rssi, -102); + }); + + test('passive RX preserves SNR RSSI and path count', () { + final observations = build(rx: [ + RxLogEntry( + timestamp: now, + repeaterId: '4E5D82AA', + snr: -1.5, + rssi: -111, + pathLength: 2, + header: 0x01, + latitude: 47.6, + longitude: -122.3, + ), + ]); + + final heard = observations.single; + expect(heard.kind, SiriObservationKind.passiveRx); + expect(heard.direct, isFalse); + expect(heard.hopCount, 2); + expect(heard.snr, -1.5); + expect(heard.rssi, -111); + }); + + test('discovery full pubkey disambiguates while failed trace is omitted', () { + const collision = Repeater( + id: 'database-456', + hexId: '4E5D99BB', + name: 'Collision', + lat: 47.7, + lon: -122.4, + lastHeard: 1787535900, + enabled: 1, + iata: 'SEA', + ); + final observations = build( + discovery: [ + DiscLogEntry( + timestamp: now, + latitude: 47.6, + longitude: -122.3, + discoveredNodes: [ + DiscoveredNodeEntry( + repeaterId: '4E5D', + nodeType: 'REPEATER', + localSnr: 6, + localRssi: -90, + remoteSnr: 4, + pubkeyHex: + '4E5D82AA00000000000000000000000000000000000000000000000000000000', + ), + ], + ), + ], + trace: [ + TraceLogEntry( + timestamp: now, + latitude: 47.6, + longitude: -122.3, + targetRepeaterId: '4E5D82AA', + success: false, + ), + ], + repeaters: const [capitolHill, collision], + ); + + expect(observations, hasLength(1)); + expect(observations.single.kind, SiriObservationKind.discovery); + expect(observations.single.name, 'Capitol Hill'); + }); + + test('discovery without enough identity never guesses a collision', () { + const collision = Repeater( + id: 'database-456', + hexId: '4E5D99BB', + name: 'Collision', + lat: 47.7, + lon: -122.4, + lastHeard: 1787535900, + enabled: 1, + iata: 'SEA', + ); + final observations = build( + discovery: [ + DiscLogEntry( + timestamp: now, + latitude: 47.6, + longitude: -122.3, + discoveredNodes: [ + DiscoveredNodeEntry( + repeaterId: '4E5D', + nodeType: 'REPEATER', + localSnr: 2, + localRssi: -105, + remoteSnr: 1, + ), + ], + ), + ], + repeaters: const [capitolHill, collision], + ); + + expect(observations.single.resolved, isFalse); + expect(observations.single.name, isNull); + }); + + test('successful trace resolves its exact target', () { + final observations = build(trace: [ + TraceLogEntry( + timestamp: now, + latitude: 47.6, + longitude: -122.3, + targetRepeaterId: '4E5D82AA', + localSnr: 9, + localRssi: -88, + success: true, + ), + ]); + + final heard = observations.single; + expect(heard.kind, SiriObservationKind.trace); + expect(heard.name, 'Capitol Hill'); + expect(heard.entityId, 'SEA|database-123'); + }); + + test('duplicate observations are retained as separate events', () { + final entries = [ + for (var index = 0; index < 2; index++) + RxLogEntry( + timestamp: now.subtract(Duration(seconds: index)), + repeaterId: '4E5D82AA', + pathLength: 1, + header: 0, + latitude: 47.6, + longitude: -122.3, + ), + ]; + + final observations = build(rx: entries); + expect(observations, hasLength(2)); + expect(observations.map((item) => item.entityId).toSet(), hasLength(1)); + }); + + test('history is newest-first, two-hour bounded, and capped at 64', () { + final entries = List.generate( + 70, + (index) => RxLogEntry( + timestamp: now.subtract(Duration(minutes: index)), + repeaterId: '4E5D82AA', + pathLength: 1, + header: 0, + latitude: 47.6, + longitude: -122.3, + ), + )..add(RxLogEntry( + timestamp: now.subtract(const Duration(hours: 3)), + repeaterId: '4E5D82AA', + pathLength: 1, + header: 0, + latitude: 47.6, + longitude: -122.3, + )); + + final observations = build(rx: entries); + + expect(observations, hasLength(64)); + expect(observations.first.observedAt, now); + expect( + observations.every( + (item) => now.difference(item.observedAt) <= const Duration(hours: 2), + ), + isTrue, + ); + }); + + test('the unique count sees repeaters older than the 64 newest events', () { + // 80 distinct repeaters, each heard once, newest first. The wire list keeps + // the 64 newest; the count has to answer for the whole session, so the 16 + // that fall off the end must still be credited. + final repeaters = List.generate( + 80, + (index) => Repeater( + id: 'database-$index', + hexId: (0x10000000 + index).toRadixString(16).toUpperCase(), + name: 'Repeater $index', + lat: 47.6, + lon: -122.3, + lastHeard: 1787535900, + enabled: 1, + iata: 'SEA', + ), + ); + final entries = [ + for (var index = 0; index < repeaters.length; index++) + RxLogEntry( + timestamp: now.subtract(Duration(seconds: index)), + repeaterId: repeaters[index].hexId, + pathLength: 1, + header: 0, + latitude: 47.6, + longitude: -122.3, + ), + ]; + + final heard = buildHeard(rx: entries, repeaters: repeaters); + + expect( + heard.observations, + hasLength(64), + reason: 'the capped wire list is what used to be counted, and it lies', + ); + expect(heard.distinctHeard, hasLength(80)); + expect( + SiriSnapshotBuilder.countUniqueRepeatersHeard( + heard.distinctHeard, + now.subtract(const Duration(minutes: 5)), + ), + 80, + ); + }); + + test('a repeater heard only outside the session is not counted', () { + final entries = [ + RxLogEntry( + timestamp: now.subtract(const Duration(minutes: 90)), + repeaterId: '4E5D82AA', + pathLength: 1, + header: 0, + latitude: 47.6, + longitude: -122.3, + ), + ]; + + final heard = buildHeard(rx: entries); + + expect(heard.distinctHeard, hasLength(1)); + expect( + SiriSnapshotBuilder.countUniqueRepeatersHeard( + heard.distinctHeard, + now.subtract(const Duration(minutes: 5)), + ), + 0, + ); + }); + + test('a repeater with no IATA keys the same on both sides of the join', () { + const zoneless = Repeater( + id: 'database-999', + hexId: '77889900', + name: 'Zoneless', + lat: 47.6, + lon: -122.3, + lastHeard: 1787535900, + enabled: 1, + ); + final heard = buildHeard( + rx: [ + RxLogEntry( + timestamp: now, + repeaterId: '77889900', + pathLength: 1, + header: 0, + latitude: 47.6, + longitude: -122.3, + ), + ], + repeaters: const [zoneless], + ); + final catalog = SiriSnapshotBuilder.buildRepeaterCatalog(const [zoneless]); + + expect(heard.observations.single.entityId, 'GLOBAL|database-999'); + expect(catalog.single.id, heard.observations.single.entityId); + }); + + test('a repeater with no IATA is keyed GLOBAL, never the live zone', () { + const zoneless = Repeater( + id: 'database-999', + hexId: '77889900', + name: 'Zoneless', + lat: 47.6, + lon: -122.3, + lastHeard: 1787535900, + enabled: 1, + ); + + // Shortcuts resolves a saved RepeaterEntity by exact id, so this string + // must not move when the session's zone does. + expect( + SiriSnapshotBuilder.repeaterEntityId(zoneless), + 'GLOBAL|database-999', + ); + expect( + SiriSnapshotBuilder.buildRepeaterCatalog(const [zoneless]).single.id, + 'GLOBAL|database-999', + ); + }); + + test('one repeater met by discovery and by short-hash echo counts once', () { + // Discovery carries the full public key and resolves; the TX echo carries + // only the 2-byte path hash, which is ambiguous against the catalogue and + // resolves to nothing. Counted raw that is the same radio twice. + final at = now.subtract(const Duration(minutes: 1)); + final heard = buildHeard( + discovery: [ + DiscLogEntry( + timestamp: at, + latitude: 47.6, + longitude: -122.3, + discoveredNodes: [ + DiscoveredNodeEntry( + repeaterId: '4E5D', + nodeType: 'REPEATER', + localSnr: 6, + localRssi: -80, + remoteSnr: 4, + pubkeyHex: + '4E5D82AA00000000000000000000000000000000000000000000000000000000', + ), + ], + ), + ], + tx: [ + TxLogEntry( + timestamp: at.add(const Duration(seconds: 10)), + latitude: 47.6, + longitude: -122.3, + power: 1, + events: [RxEvent(repeaterId: '4E5D', snr: 5.0, rssi: -82)], + ), + ], + ); + + expect(heard.distinctHeard, hasLength(2)); + expect( + SiriSnapshotBuilder.countUniqueRepeatersHeard( + heard.distinctHeard, + now.subtract(const Duration(minutes: 5)), + ), + 1, + ); + }); + + test('an ambiguous hash stays its own entry rather than being guessed at', + () { + const twin = Repeater( + id: 'database-456', + hexId: '4E5DFFFF', + name: 'Twin', + lat: 47.63, + lon: -122.33, + lastHeard: 1787535900, + enabled: 1, + iata: 'SEA', + ); + final at = now.subtract(const Duration(minutes: 1)); + final heard = buildHeard( + repeaters: const [capitolHill, twin], + discovery: [ + DiscLogEntry( + timestamp: at, + latitude: 47.6, + longitude: -122.3, + discoveredNodes: [ + DiscoveredNodeEntry( + repeaterId: '4E5D', + nodeType: 'REPEATER', + localSnr: 6, + localRssi: -80, + remoteSnr: 4, + pubkeyHex: + '4E5D82AA00000000000000000000000000000000000000000000000000000000', + ), + DiscoveredNodeEntry( + repeaterId: '4E5D', + nodeType: 'REPEATER', + localSnr: 4, + localRssi: -90, + remoteSnr: 2, + pubkeyHex: + '4E5DFFFF00000000000000000000000000000000000000000000000000000000', + ), + ], + ), + ], + tx: [ + TxLogEntry( + timestamp: at.add(const Duration(seconds: 10)), + latitude: 47.6, + longitude: -122.3, + power: 1, + events: [RxEvent(repeaterId: '4E5D', snr: 5.0, rssi: -82)], + ), + ], + ); + + expect( + SiriSnapshotBuilder.countUniqueRepeatersHeard( + heard.distinctHeard, + now.subtract(const Duration(minutes: 5)), + ), + 3, + reason: 'both twins plus the hash that could be either', + ); + }); + + group('unique repeaters heard is scoped to the running session', () { + SiriHeardIdentity heard(String hexId, Duration ago) => SiriHeardIdentity( + key: 'SEA|$hexId', + observedAt: now.subtract(ago), + ); + + test('a previous session\'s repeaters are not credited to this one', () { + final observations = [ + heard('AAAA1111', const Duration(minutes: 2)), + heard('BBBB2222', const Duration(minutes: 90)), + heard('CCCC3333', const Duration(minutes: 100)), + ]; + + expect( + SiriSnapshotBuilder.countUniqueRepeatersHeard( + observations, + now.subtract(const Duration(minutes: 5)), + ), + 1, + ); + }); + + test('an observation made at the boundary belongs to the session', () { + // The session's own first discovery is sent and recorded before the + // boundary is stored, so the boundary has to be inclusive and has to be + // captured before that transmission; otherwise a fresh Passive session + // reports nothing for its opening event. + final startedAt = now.subtract(const Duration(minutes: 5)); + final observations = [ + SiriHeardIdentity(key: 'SEA|first', observedAt: startedAt), + ]; + + expect( + SiriSnapshotBuilder.countUniqueRepeatersHeard(observations, startedAt), + 1, + ); + }); + + test('an observation one millisecond earlier is a previous session', () { + final startedAt = now.subtract(const Duration(minutes: 5)); + final observations = [ + SiriHeardIdentity( + key: 'SEA|earlier', + observedAt: startedAt.subtract(const Duration(milliseconds: 1)), + ), + ]; + + expect( + SiriSnapshotBuilder.countUniqueRepeatersHeard(observations, startedAt), + 0, + ); + }); + + test('a session that has heard nothing yet reports zero', () { + final observations = [heard('BBBB2222', const Duration(minutes: 90))]; + + expect( + SiriSnapshotBuilder.countUniqueRepeatersHeard( + observations, + now.subtract(const Duration(seconds: 10)), + ), + 0, + ); + }); + + test('no running session means nothing to count', () { + expect( + SiriSnapshotBuilder.countUniqueRepeatersHeard( + [heard('AAAA1111', const Duration(minutes: 2))], + null, + ), + 0, + ); + }); + + test('one repeater heard repeatedly still counts once', () { + final observations = [ + heard('AAAA1111', const Duration(minutes: 1)), + heard('AAAA1111', const Duration(minutes: 2)), + heard('AAAA1111', const Duration(minutes: 3)), + ]; + + expect( + SiriSnapshotBuilder.countUniqueRepeatersHeard( + observations, + now.subtract(const Duration(minutes: 10)), + ), + 1, + ); + }); + + test('unresolved observations count by hex, not collapsed together', () { + final observations = [ + SiriHeardIdentity(key: 'unresolved:DDDD4444', observedAt: now), + SiriHeardIdentity(key: 'unresolved:EEEE5555', observedAt: now), + ]; + + expect( + SiriSnapshotBuilder.countUniqueRepeatersHeard( + observations, + now.subtract(const Duration(minutes: 1)), + ), + 2, + ); + }); + }); + + test('non-finite radio and repeater values cannot poison the snapshot', () { + const invalidLocation = Repeater( + id: 'invalid-location', + hexId: 'AABBCCDD', + name: 'Invalid Location', + lat: double.infinity, + lon: -122.3, + lastHeard: 1787535900, + enabled: 1, + iata: 'SEA', + ); + final observations = build( + rx: [ + RxLogEntry( + timestamp: now, + repeaterId: 'AABBCCDD', + snr: double.nan, + pathLength: 1, + header: 0, + latitude: 47.6, + longitude: -122.3, + ), + ], + repeaters: const [invalidLocation], + ); + + expect(observations.single.snr, isNull); + expect(observations.single.distanceM, isNull); + expect(observations.single.repeaterLat, isNull); + expect(observations.single.repeaterLon, isNull); + + final catalog = SiriSnapshotBuilder.buildRepeaterCatalog( + const [invalidLocation], + ); + expect(catalog.single.latitude, isNull); + expect(catalog.single.longitude, isNull); + }); + + test('repeater catalogue is active-first, recent-first, and bounded', () { + final nowSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final repeaters = List.generate( + SiriSnapshotBuilder.maximumRepeaters + 10, + (index) => Repeater( + id: 'repeater-$index', + hexId: index.toRadixString(16).padLeft(8, '0'), + name: 'Repeater $index', + lat: 47.0 + index / 1000, + lon: -122.0, + lastHeard: nowSeconds - index, + enabled: 1, + iata: 'SEA', + staleTime: index == 7 ? nowSeconds + 3600 : nowSeconds - 1, + ), + ); + + final catalog = SiriSnapshotBuilder.buildRepeaterCatalog(repeaters); + + expect(catalog, hasLength(SiriSnapshotBuilder.maximumRepeaters)); + expect(catalog.first.id, 'SEA|repeater-7'); + expect(catalog[1].id, 'SEA|repeater-0'); + }); +} diff --git a/test/services/app_intents/siri_snapshot_wire_contract_test.dart b/test/services/app_intents/siri_snapshot_wire_contract_test.dart new file mode 100644 index 00000000..0b4308d3 --- /dev/null +++ b/test/services/app_intents/siri_snapshot_wire_contract_test.dart @@ -0,0 +1,148 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/app_intents/siri_snapshot_models.dart'; + +void main() { + test('Dart snapshot keys match the Swift Codable mirror exactly', () { + final at = DateTime.fromMillisecondsSinceEpoch(1787535920000); + final snapshot = SiriSnapshot( + updatedAt: at, + connection: const SiriConnectionSnapshot( + isConnected: true, + deviceName: 'MrAlders0n', + batteryPercent: 76, + gpsStatus: 'locked', + ), + session: SiriSessionSnapshot( + id: 'session-1', + startedAt: at.subtract(const Duration(minutes: 4)), + active: true, + starting: false, + mode: 'hybrid', + phase: 'listening', + phaseTitle: 'Listening', + phaseDetail: 'Waiting for echoes', + phaseEndsAt: at, + zoneCode: 'SEA', + txCount: 12, + rxCount: 8, + discoveryCount: 4, + traceCount: 0, + queueSize: 0, + uniqueRepeatersHeard: 9, + ), + controls: SiriControlsSnapshot( + availableStartModes: const ['passive', 'active', 'hybrid'], + canStart: false, + startBlockedReason: 'Already running', + canStop: true, + canManualPing: false, + manualPingBlockedReason: 'Cooling down', + manualCooldownEndsAt: at, + ), + recentHeard: [ + SiriRepeaterObservation( + entityId: 'SEA|database-123', + displayHexId: '4E5D', + name: 'Capitol Hill', + observedAt: at, + kind: SiriObservationKind.txEcho, + direct: true, + hopCount: 1, + snr: 8.5, + rssi: -91, + distanceM: 2149.3, + repeaterLat: 47.6202, + repeaterLon: -122.3194, + resolved: true, + ), + ], + repeaters: [ + SiriRepeaterEntitySnapshot( + id: 'SEA|database-123', + name: 'Capitol Hill', + hexId: '4E5D82AA', + zoneCode: 'SEA', + isActive: true, + isNew: false, + serverLastHeard: at, + latitude: 47.6202, + longitude: -122.3194, + ), + ], + ).toMap(); + + expect(snapshot.keys.toSet(), { + 'version', + 'updatedAtMs', + 'connection', + 'session', + 'controls', + 'recentHeard', + 'repeaters', + }); + expect((snapshot['connection']! as Map).keys.toSet(), { + 'isConnected', + 'deviceName', + 'batteryPercent', + 'gpsStatus', + }); + expect((snapshot['session']! as Map).keys.toSet(), { + 'id', + 'startedAtMs', + 'active', + 'starting', + 'mode', + 'phase', + 'phaseTitle', + 'phaseDetail', + 'phaseEndsAtMs', + 'zoneCode', + 'txCount', + 'rxCount', + 'discoveryCount', + 'traceCount', + 'queueSize', + 'uniqueRepeatersHeard', + }); + expect((snapshot['controls']! as Map).keys.toSet(), { + 'availableStartModes', + 'canStart', + 'startBlockedReason', + 'canStop', + 'canManualPing', + 'manualPingBlockedReason', + 'manualCooldownEndsAtMs', + }); + expect(((snapshot['recentHeard']! as List).single as Map).keys.toSet(), { + 'entityId', + 'displayHexId', + 'name', + 'observedAtMs', + 'kind', + 'direct', + 'hopCount', + 'snr', + 'rssi', + 'distanceM', + 'repeaterLat', + 'repeaterLon', + 'resolved', + }); + expect(((snapshot['repeaters']! as List).single as Map).keys.toSet(), { + 'id', + 'name', + 'hexId', + 'zoneCode', + 'isActive', + 'isNew', + 'serverLastHeardMs', + 'latitude', + 'longitude', + }); + + final encoded = snapshot.toString(); + expect(encoded, isNot(contains('currentUserLatitude'))); + expect(encoded, isNot(contains('observationUserLatitude'))); + expect(encoded, isNot(contains('observationUserLongitude'))); + }); +} diff --git a/test/services/auto/android_auto_service_test.dart b/test/services/auto/android_auto_service_test.dart new file mode 100644 index 00000000..8a7310e1 --- /dev/null +++ b/test/services/auto/android_auto_service_test.dart @@ -0,0 +1,423 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_carplay/controllers/android_auto_controller.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/auto/android_auto_service.dart'; +import 'package:mesh_mapper/services/auto/auto_glance_view.dart'; +import 'package:mesh_mapper/services/auto/car_map_channel.dart'; +import 'package:mesh_mapper/services/external_surfaces/geo/external_surface_geo_models.dart'; +import 'package:mesh_mapper/services/live_activity/live_activity_models.dart'; +import 'package:mesh_mapper/services/watch/watch_models.dart'; + +const String _methodChannelName = 'com.oguzhnatly.flutter_android_auto'; +const String _carMapChannelName = 'meshmapper/car_map_ordering_test'; +const String _eventChannelName = 'com.oguzhnatly.flutter_android_auto/event'; + +final DateTime _now = DateTime.fromMillisecondsSinceEpoch(1760000000000); + +WatchSnapshot _snapshot({ + bool withPosition = false, + int txCount = 0, + bool isSessionActive = true, + String sessionId = 'session-1', + LiveActivityPhase phase = LiveActivityPhase.listening, + String phaseTitle = 'Listening', +}) => + WatchSnapshot( + core: LiveActivitySnapshot( + sessionId: sessionId, + mode: 'Active', + phase: phase, + phaseTitle: phaseTitle, + phaseDetail: 'Waiting for echoes', + isConnected: true, + zoneCode: 'SEA', + txCount: txCount, + rxCount: 0, + discoveryCount: 0, + traceCount: 0, + queueSize: 0, + repeaters: const [], + totalHeardCount: 0, + repeatersAreCurrent: true, + updatedAt: _now, + ), + geo: ExternalSurfaceGeo( + pings: const [], + repeaters: const [], + heard: const [], + linkedRepeaterIds: const [], + you: withPosition + ? ExternalSurfacePosition(lat: 47.6, lon: -122.3, fixedAt: _now) + : null, + ), + controls: WatchControls( + canStartStop: true, + canManualPing: false, + isSessionActive: isSessionActive, + ), + updatedAt: _now, + ); + +/// Records what the surface sends native, so a test can assert on the sequence +/// rather than on the plugin's internals. +class _NativeRecorder { + final List calls = []; + final List> templates = >[]; + + void install() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + const MethodChannel(_methodChannelName), + (MethodCall call) async { + calls.add(call.method); + final args = call.arguments; + if (args is Map && args['template'] is Map) { + templates.add(args['template'] as Map); + } + return true; + }, + ); + // The event channel's listen/cancel arrive as method calls on a channel of + // the same name. + messenger.setMockMethodCallHandler( + const MethodChannel(_eventChannelName), + (MethodCall call) async => null, + ); + } + + void clear() { + calls.clear(); + templates.clear(); + } +} + +Future _emit(Object event) async { + const codec = StandardMethodCodec(); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _eventChannelName, + codec.encodeSuccessEnvelope(event), + (_) {}, + ); +} + +Future _connect() => _emit({ + 'type': 'onAndroidAutoConnectionChange', + 'data': {'status': 'connected'}, + }); + +Future _disconnect() => _emit({ + 'type': 'onAndroidAutoConnectionChange', + 'data': {'status': 'disconnected'}, + }); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _NativeRecorder native; + late AndroidAutoService service; + late WatchSnapshot current; + + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + // Static across instances in the plugin; a leftover entry would make an + // update look like it matched when it did not. + FlutterAndroidAutoController.templateHistory.clear(); + native = _NativeRecorder()..install(); + current = _snapshot(); + }); + + tearDown(() { + service.dispose(); + debugDefaultTargetPlatformOverride = null; + }); + + AndroidAutoService build({ + Duration debounce = Duration.zero, + Duration floor = const Duration(seconds: 1), + AutoCommandHandler? onCommand, + AutoCommandRefusalHandler? onRefusal, + CarMapSettingsBuilder? settings, + Future Function(String)? markerRenderer, + }) { + service = AndroidAutoService( + debounceDelay: debounce, + minimumNonUrgentInterval: floor, + carMap: CarMapChannel(channel: const MethodChannel(_carMapChannelName)), + markerRenderer: markerRenderer, + )..attach( + snapshotBuilder: () => current, + commandHandler: onCommand ?? (_) => null, + onRefusal: onRefusal, + carMapSettingsBuilder: settings, + ); + return service; + } + + /// The ordering this pins is load-bearing and easy to lose. + /// + /// `_publish` runs `_syncMap` *above* the fingerprint gate. A settings change + /// alters no pane text, so its fingerprint is unchanged and the gate returns + /// early — if the map sync sat below it, every settings change would be + /// swallowed and the head unit would quietly keep the old style, palette and + /// coverage. That failure is invisible in tests and only shows up in a car. + group('settings reach the map even when the pane does not change', () { + late List carMapCalls; + + setUp(() { + carMapCalls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel(_carMapChannelName), + (call) async { + carMapCalls.add(call.method); + return true; + }, + ); + }); + + /// Rendering the marker rasterizes a CustomPainter, which needs the + /// engine's raster thread — and a backgrounded phone, which is exactly the + /// car case, may not be pumping it. When that render ran before the camera, + /// one stall left the camera unset and the head unit showed a whole-world + /// view over null island. Nothing cosmetic may precede the thing that + /// decides where the map looks. + test('the camera is sent even when the marker cannot be rendered', + () async { + build( + settings: () => CarMapSettings.from( + styleUrl: 'https://tiles.openfreemap.org/styles/dark', + zoneCode: 'sea', + tilesEnabled: true, + gridSize: 300, + colorVisionType: 'none', + opacity: 0.7, + mapAlwaysNorth: true, + mapRotationLocked: false, + markerStyle: 'arrow', + markerFacesHeading: true, + ), + markerRenderer: (_) => Future.error( + StateError('no rasterizer'), + ), + ); + current = _snapshot(withPosition: true); + await _connect(); + await pumpEventQueue(); + + expect(carMapCalls, contains('setCamera')); + }); + + test('a publish with an unchanged fingerprint still syncs the map', + () async { + build( + settings: () => CarMapSettings.from( + styleUrl: 'https://tiles.openfreemap.org/styles/dark', + zoneCode: 'sea', + tilesEnabled: true, + gridSize: 300, + colorVisionType: 'none', + opacity: 0.7, + mapAlwaysNorth: true, + mapRotationLocked: false, + markerStyle: 'arrow', + markerFacesHeading: true, + ), + ); + await _connect(); + await pumpEventQueue(); + + // Everything the pane renders is identical, so the template is skipped. + native.clear(); + carMapCalls.clear(); + service.schedule(immediate: true); + await pumpEventQueue(); + + expect(native.calls, isEmpty, reason: 'the pane really is unchanged'); + expect(carMapCalls, contains('setStyle'), + reason: 'but the map must still be told'); + }); + + /// The pane's update floor is quota insurance for template refreshes. The + /// map costs no quota, and a driver's camera must not inherit a text + /// throttle: at a one-second floor the map would lag a moving vehicle by up + /// to a second, and at the thirty here it would look frozen. This is why + /// the two run on separate paths rather than sharing one publisher. + test('the pane floor does not throttle the map', () async { + build( + floor: const Duration(seconds: 30), + settings: () => CarMapSettings.from( + styleUrl: 'https://tiles.openfreemap.org/styles/dark', + zoneCode: 'sea', + tilesEnabled: true, + gridSize: 300, + colorVisionType: 'none', + opacity: 0.7, + mapAlwaysNorth: true, + mapRotationLocked: false, + markerStyle: 'arrow', + markerFacesHeading: true, + ), + ); + await _connect(); + await pumpEventQueue(); + native.clear(); + carMapCalls.clear(); + + // A counter change: the pane's text differs, but nothing in it is urgent, + // so the floor holds the template back. + current = _snapshot(txCount: 1); + service.schedule(immediate: true); + await pumpEventQueue(); + + expect(native.calls, isEmpty, reason: 'the pane is inside its floor'); + expect(carMapCalls, contains('setStyle'), + reason: 'the map is not, and never was'); + }); + }); + + test('publishes nothing until the car connects', () async { + build().schedule(immediate: true); + await pumpEventQueue(); + expect(native.calls, isEmpty); + }); + + test('always re-sets the root template, never updatePaneTemplate', () async { + build(); + await _connect(); + await pumpEventQueue(); + expect(native.calls, ['setRootTemplate']); + + native.clear(); + current = + _snapshot(phase: LiveActivityPhase.sending, phaseTitle: 'Sending'); + service.schedule(immediate: true); + await pumpEventQueue(); + // updatePaneTemplate rebuilds the addressed element *as* the root, which + // would swap the map out for a bare pane. + expect(native.calls, ['setRootTemplate']); + }); + + test('the root is a map template wrapping the pane', () async { + build(); + await _connect(); + await pumpEventQueue(); + + final root = native.templates.single; + expect(root['contentRuntimeType'], 'FAAPaneTemplate'); + final content = root['contentTemplate'] as Map; + expect(content['_elementId'], autoGlanceTemplateId); + // An empty title means no header row. The panel's width is the host's, so + // the header was the only part of it we could give back to the map — and a + // stray title would quietly take it again. + expect(content['title'], isEmpty); + expect((content['items'] as List), hasLength(1), + reason: 'one status row, not a menu — the map needs the space'); + expect((content['actions'] as List), isEmpty, + reason: 'the controls are in the map action strip'); + expect((root['mapActions'] as List), hasLength(1), + reason: 'one toggle button, not a permanent Start and Stop pair'); + }); + + test('every update reuses the root template id', () async { + build(); + await _connect(); + await pumpEventQueue(); + current = + _snapshot(phase: LiveActivityPhase.sending, phaseTitle: 'Sending'); + service.schedule(immediate: true); + await pumpEventQueue(); + + // A fresh uuid per rebuild would leave the plugin matching nothing in its + // template history, and the surface would silently desync. + expect(native.templates, hasLength(2)); + for (final template in native.templates) { + expect(template['_elementId'], autoGlanceMapTemplateId); + expect( + (template['contentTemplate'] as Map)['_elementId'], + autoGlanceTemplateId, + ); + } + }); + + test('identical content is not sent twice', () async { + build(); + await _connect(); + await pumpEventQueue(); + native.clear(); + + service.schedule(immediate: true); + service.schedule(immediate: true); + await pumpEventQueue(); + expect(native.calls, isEmpty); + }); + + test('counter churn waits out the floor', () async { + build(floor: const Duration(milliseconds: 200)); + await _connect(); + await pumpEventQueue(); + native.clear(); + + current = _snapshot(txCount: 1); + service.schedule(immediate: true); + await pumpEventQueue(); + expect(native.calls, isEmpty, reason: 'inside the floor'); + + await Future.delayed(const Duration(milliseconds: 260)); + await pumpEventQueue(); + expect(native.calls, ['setRootTemplate'], + reason: 'deferred, not dropped — the last pings of a run still draw'); + }); + + test('an urgent change bypasses the floor', () async { + build(floor: const Duration(seconds: 30)); + await _connect(); + await pumpEventQueue(); + native.clear(); + + current = + _snapshot(phase: LiveActivityPhase.sending, phaseTitle: 'Sending'); + service.schedule(immediate: true); + await pumpEventQueue(); + expect(native.calls, ['setRootTemplate']); + }); + + test('a reconnect sends a fresh root template', () async { + build(); + await _connect(); + await pumpEventQueue(); + native.clear(); + + await _disconnect(); + await _connect(); + await pumpEventQueue(); + expect(native.calls, ['setRootTemplate'], + reason: + 'the host no longer holds the template history we updated into'); + }); + + test('a disconnected surface publishes nothing', () async { + build(); + await _connect(); + await pumpEventQueue(); + await _disconnect(); + native.clear(); + + current = + _snapshot(phase: LiveActivityPhase.sending, phaseTitle: 'Sending'); + service.schedule(immediate: true); + await pumpEventQueue(); + expect(native.calls, isEmpty); + }); + + test('is inert off Android', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + build(); + expect(service.isSupportedPlatform, isFalse); + await _connect(); + await pumpEventQueue(); + expect(native.calls, isEmpty); + }); +} diff --git a/test/services/auto/auto_command_routing_test.dart b/test/services/auto/auto_command_routing_test.dart new file mode 100644 index 00000000..75588009 --- /dev/null +++ b/test/services/auto/auto_command_routing_test.dart @@ -0,0 +1,255 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_carplay/controllers/android_auto_controller.dart'; +import 'package:flutter_carplay/flutter_carplay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/auto/android_auto_service.dart'; +import 'package:mesh_mapper/services/auto/auto_glance_view.dart'; +import 'package:mesh_mapper/services/external_commands/external_command_models.dart'; +import 'package:mesh_mapper/services/external_commands/external_session_commands.dart'; +import 'package:mesh_mapper/services/live_activity/live_activity_models.dart'; +import 'package:mesh_mapper/services/watch/watch_models.dart'; + +const String _methodChannelName = 'com.oguzhnatly.flutter_android_auto'; +const String _eventChannelName = 'com.oguzhnatly.flutter_android_auto/event'; + +final DateTime _now = DateTime.fromMillisecondsSinceEpoch(1760000000000); + +WatchSnapshot _snapshot({ + required String sessionId, + required bool isSessionActive, +}) => + WatchSnapshot( + core: LiveActivitySnapshot( + sessionId: sessionId, + mode: 'Active', + phase: LiveActivityPhase.listening, + phaseTitle: isSessionActive ? 'Listening' : 'Ready', + phaseDetail: isSessionActive ? 'Waiting for echoes' : null, + isConnected: true, + zoneCode: 'SEA', + txCount: 0, + rxCount: 0, + discoveryCount: 0, + traceCount: 0, + queueSize: 0, + repeaters: const [], + totalHeardCount: 0, + repeatersAreCurrent: true, + updatedAt: _now, + ), + geo: const WatchGeo( + pings: [], + repeaters: [], + heard: [], + linkedRepeaterIds: [], + ), + controls: WatchControls( + canStartStop: true, + canManualPing: false, + isSessionActive: isSessionActive, + ), + updatedAt: _now, + ); + +/// The last template the surface sent native, decoded back into the action +/// closures the plugin would invoke on a tap. +/// +/// The controls live in the map action strip, not the pane: the pane was filling +/// half the head unit, and the strip is the vertical bar the host draws down the +/// right edge of the map. +List _renderedActions() => + (FlutterAndroidAutoController.templateHistory.last + as AAMapWithContentTemplate) + .mapActions; + +void _tap(String actionId) { + _renderedActions() + .firstWhere((action) => action.uniqueId == actionId) + .onPress!(); +} + +Future _emitConnected() async { + const codec = StandardMethodCodec(); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _eventChannelName, + codec.encodeSuccessEnvelope({ + 'type': 'onAndroidAutoConnectionChange', + 'data': {'status': 'connected'}, + }), + (_) {}, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AndroidAutoService service; + late List received; + late List refusals; + late WatchSnapshot current; + ExternalCommandReason? nextRefusal; + + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + FlutterAndroidAutoController.templateHistory.clear(); + received = []; + refusals = []; + nextRefusal = null; + current = _snapshot(sessionId: 'session-1', isSessionActive: true); + + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + const MethodChannel(_methodChannelName), + (MethodCall call) async => true, + ); + messenger.setMockMethodCallHandler( + const MethodChannel(_eventChannelName), + (MethodCall call) async => null, + ); + + service = AndroidAutoService( + debounceDelay: Duration.zero, + minimumNonUrgentInterval: Duration.zero, + )..attach( + snapshotBuilder: () => current, + commandHandler: (command) { + received.add(command); + return nextRefusal; + }, + onRefusal: refusals.add, + ); + }); + + tearDown(() { + service.dispose(); + debugDefaultTargetPlatformOverride = null; + }); + + test('the strip carries one toggle action', () async { + await _emitConnected(); + await pumpEventQueue(); + + // One button, not two: Start and Stop are mutually exclusive, so showing + // both meant one of them was always the wrong thing to press. + final actions = _renderedActions(); + expect(actions, hasLength(1)); + expect(actions.single.uniqueId, autoGlanceToggleActionId); + expect(actions.single.isPrimary, isTrue); + // The map strip is icon-only — ACTIONS_CONSTRAINTS_MAP leaves + // maxCustomTitles at zero, so an action without an icon is rejected and the + // whole template goes with it. + for (final action in actions) { + expect(action.imageUrl, isNotEmpty); + } + }); + + test('Start sends no mode, deferring to the mode the screen promised', + () async { + // Idle, so the toggle points at Start. + current = _snapshot(sessionId: 'session-1', isSessionActive: false); + await _emitConnected(); + await pumpEventQueue(); + + _tap(autoGlanceToggleActionId); + expect(received, hasLength(1)); + expect(received.single.kind, ExternalSessionCommandKind.startSession); + expect(received.single.source, ExternalCommandSource.androidAuto); + // Null defers to the phone, which resolves it to the same value the + // status row renders. Naming a mode here would let the button start + // something other than what the driver was shown. + expect(received.single.mode, isNull); + }); + + test('Stop names the session the screen was rendering', () async { + await _emitConnected(); + await pumpEventQueue(); + + _tap(autoGlanceToggleActionId); + expect(received.single.kind, ExternalSessionCommandKind.stopSession); + expect(received.single.sessionId, 'session-1'); + }); + + test('with no session the button starts one instead', () async { + current = _snapshot(sessionId: 'session-1', isSessionActive: false); + await _emitConnected(); + await pumpEventQueue(); + + // With no session the button points the other way: it starts one. + _tap(autoGlanceToggleActionId); + expect(received.single.kind, ExternalSessionCommandKind.startSession); + }); + + test('a Stop from a stale screen is refused rather than stopping the new run', + () async { + await _emitConnected(); + await pumpEventQueue(); + + // The driver is looking at a screen for session-1. It ends and session-2 + // begins; a fresh snapshot is published. + final staleActions = _renderedActions(); + current = _snapshot(sessionId: 'session-2', isSessionActive: true); + service.schedule(immediate: true); + await pumpEventQueue(); + + // They tap the Stop they were looking at. + staleActions + .firstWhere((action) => action.uniqueId == autoGlanceToggleActionId) + .onPress!(); + + final command = received.single; + expect(command.sessionId, 'session-1'); + + // Which is exactly what the provider's guard needs to refuse it — and it + // is the shared guard, reached with the very command the strip built. + final admission = resolveExternalSessionTransition( + command: command, + isSessionActive: true, + isSessionStarting: false, + currentMode: 'passive', + currentSessionId: 'session-2', + currentModeLabel: 'Passive', + ); + expect(admission.shouldExecute, isFalse); + expect(admission.reason, ExternalCommandReason.sessionAlreadyEnded); + }); + + test('a refusal reaches the refusal handler', () async { + await _emitConnected(); + await pumpEventQueue(); + + nextRefusal = ExternalCommandReason.notConnected; + _tap(autoGlanceToggleActionId); + await pumpEventQueue(); + expect(refusals, [ExternalCommandReason.notConnected]); + }); + + test('an admitted command reports no refusal', () async { + await _emitConnected(); + await pumpEventQueue(); + + _tap(autoGlanceToggleActionId); + await pumpEventQueue(); + expect(refusals, isEmpty); + }); + + test('an asynchronous refusal still reaches the handler', () async { + service.dispose(); + service = AndroidAutoService( + debounceDelay: Duration.zero, + minimumNonUrgentInterval: Duration.zero, + )..attach( + snapshotBuilder: () => current, + commandHandler: (command) async => ExternalCommandReason.couldNotStart, + onRefusal: refusals.add, + ); + await _emitConnected(); + await pumpEventQueue(); + + _tap(autoGlanceToggleActionId); + await pumpEventQueue(); + expect(refusals, [ExternalCommandReason.couldNotStart]); + }); +} diff --git a/test/services/auto/auto_glance_view_test.dart b/test/services/auto/auto_glance_view_test.dart new file mode 100644 index 00000000..edc22e8c --- /dev/null +++ b/test/services/auto/auto_glance_view_test.dart @@ -0,0 +1,275 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/auto/auto_glance_view.dart'; +import 'package:mesh_mapper/services/live_activity/live_activity_models.dart'; +import 'package:mesh_mapper/services/watch/watch_models.dart'; + +final DateTime _now = DateTime.fromMillisecondsSinceEpoch(1760000000000); + +WatchSnapshot _snapshot({ + WatchHapticCue? cue, + String phaseTitle = 'Listening', + String? phaseDetail = 'Waiting for echoes', + LiveActivityPhase phase = LiveActivityPhase.listening, + bool isConnected = true, + bool isSessionActive = true, + String sessionId = 'session-1', + String mode = 'Active', + String? zoneCode = 'SEA', + String? blockedReason, + int txCount = 3, + int rxCount = 2, + int discoveryCount = 1, + int queueSize = 4, + WatchGeo? geo, +}) => + WatchSnapshot( + core: LiveActivitySnapshot( + sessionId: sessionId, + mode: mode, + phase: phase, + phaseTitle: phaseTitle, + phaseDetail: phaseDetail, + isConnected: isConnected, + zoneCode: zoneCode, + txCount: txCount, + rxCount: rxCount, + discoveryCount: discoveryCount, + traceCount: 0, + queueSize: queueSize, + repeaters: const [], + totalHeardCount: 0, + repeatersAreCurrent: true, + updatedAt: _now, + ), + geo: geo ?? + const WatchGeo( + pings: [], + repeaters: [], + heard: [], + linkedRepeaterIds: [], + ), + controls: WatchControls( + canStartStop: true, + canManualPing: false, + isSessionActive: isSessionActive, + blockedReason: blockedReason, + ), + cue: cue, + updatedAt: _now, + ); + +void main() { + group('layout contract', () { + // These are not cosmetic assertions. Android Auto allows a pane update to be + // a free "refresh" only while the row count and row titles are unchanged; + // anything else counts against a five-template-per-task quota, and a driver + // on a long wardrive would watch the surface stop updating. Changing what + // these pin means re-checking the quota on the Desktop Head Unit's debug + // overlay, not just re-recording the expectation. + // + // One row, not four: the content panel is what crowds the map, and the + // controls moved to the map action strip. + test('always renders exactly one row', () { + for (final snapshot in [ + _snapshot(), + _snapshot(isSessionActive: false, phase: LiveActivityPhase.idle), + _snapshot(isConnected: false, zoneCode: null, phaseDetail: null), + _snapshot(blockedReason: 'Not connected', queueSize: 0), + ]) { + final view = buildAutoGlanceView(snapshot, now: _now); + expect(view.rows, hasLength(1)); + for (final row in view.rows) { + expect(row.detail, isNotEmpty, + reason: + 'a blank row still occupies the slot but reads as broken'); + } + } + }); + }); + + group('the row title', () { + // The node name, so the driver can see which radio the numbers belong to. + // It changes only on connect, disconnect or a rename — a handful of + // templates over a session, not one per publish, so the refresh quota that + // the fixed row count protects still holds. + test('is the connected node', () { + final view = + buildAutoGlanceView(_snapshot(), now: _now, nodeName: 'Alders0n'); + expect(view.rows.single.title, 'Alders0n'); + }); + + test('falls back when nothing is connected', () { + expect( + buildAutoGlanceView(_snapshot(), now: _now).rows.single.title, + autoGlanceFallbackTitle, + ); + expect( + buildAutoGlanceView(_snapshot(), now: _now, nodeName: '') + .rows + .single + .title, + autoGlanceFallbackTitle, + ); + }); + }); + + group('the status line', () { + String lineOf(WatchSnapshot snapshot) => + buildAutoGlanceView(snapshot, now: _now).rows.single.detail; + + test('carries the phase and the counters', () { + expect( + lineOf(_snapshot( + txCount: 12, + rxCount: 7, + discoveryCount: 3, + queueSize: 0, + )), + 'Listening · Waiting for echoes · TX 12 · RX 7 · Disc 3', + ); + }); + + test('a queue backlog is worth the space, an empty queue is not', () { + expect(lineOf(_snapshot(queueSize: 5)), contains('Queue 5')); + expect(lineOf(_snapshot(queueSize: 0)), isNot(contains('Queue'))); + }); + + test('falls back to the phase title alone with no detail', () { + expect( + lineOf(_snapshot(phaseTitle: 'Ready', phaseDetail: null)), + startsWith('Ready · TX'), + ); + }); + + test('a live cue outranks the phase detail', () { + expect( + lineOf(_snapshot( + cue: WatchHapticCue( + id: 'cue-1', + kind: 'failure', + issuedAt: _now, + message: 'Not connected', + ), + )), + startsWith('Listening · Not connected'), + ); + }); + + test('a cue stops outranking it once it is no longer readable', () { + expect( + lineOf(_snapshot( + cue: WatchHapticCue( + id: 'cue-1', + kind: 'failure', + issuedAt: _now.subtract(WatchWire.cueReadableFor), + message: 'Not connected', + ), + )), + startsWith('Listening · Waiting for echoes'), + ); + }); + + // A disconnected radio is the one state where the phase alone can mislead, + // so it earns the extra words. + test('says so when the radio is disconnected', () { + expect(lineOf(_snapshot(isConnected: false)), contains('Disconnected')); + expect(lineOf(_snapshot()), isNot(contains('Disconnected'))); + }); + + test('surfaces why the next tap would be refused', () { + expect( + lineOf(_snapshot(blockedReason: 'Outside zone')), + endsWith('Outside zone'), + ); + }); + }); + + group('sessionId capture', () { + test('is the running session', () { + final view = buildAutoGlanceView(_snapshot(sessionId: 'abc'), now: _now); + expect(view.sessionId, 'abc'); + }); + + // Carrying an id past the end of its session would let a Stop name a run + // that has already stopped, which is exactly what the admission guard exists + // to catch — so do not hand it one. + test('is null when no session is running', () { + final view = buildAutoGlanceView( + _snapshot(isSessionActive: false), + now: _now, + ); + expect(view.sessionId, isNull); + }); + }); + + group('urgencyKey', () { + String keyOf(WatchSnapshot s) => + buildAutoGlanceView(s, now: _now).urgencyKey; + + // Counters move constantly during a session. If they were urgent, the floor + // that keeps the pane inside its template quota would never apply. + test('does not change on counters alone', () { + expect( + keyOf(_snapshot(txCount: 99, rxCount: 98, discoveryCount: 97)), + keyOf(_snapshot()), + ); + }); + + test('changes when the session changes', () { + expect(keyOf(_snapshot(sessionId: 'other')), isNot(keyOf(_snapshot()))); + }); + + test('changes when the session stops', () { + expect( + keyOf(_snapshot(isSessionActive: false)), + isNot(keyOf(_snapshot())), + ); + }); + + test('changes when the phase changes', () { + expect( + keyOf(_snapshot(phase: LiveActivityPhase.sending)), + isNot(keyOf(_snapshot())), + ); + }); + + test('changes when the radio connects or drops', () { + expect(keyOf(_snapshot(isConnected: false)), isNot(keyOf(_snapshot()))); + }); + + test('changes when a new cue arrives', () { + final cued = _snapshot( + cue: WatchHapticCue( + id: 'cue-1', + kind: 'failure', + issuedAt: _now, + message: 'Could not start', + ), + ); + expect(keyOf(cued), isNot(keyOf(_snapshot()))); + }); + + test('changes when the blocked reason changes', () { + expect( + keyOf(_snapshot(blockedReason: 'Not connected')), + isNot(keyOf(_snapshot())), + ); + }); + }); + + group('fingerprint', () { + test('is identical for identical content', () { + expect( + buildAutoGlanceView(_snapshot(), now: _now).fingerprint, + buildAutoGlanceView(_snapshot(), now: _now).fingerprint, + ); + }); + + test('changes when a counter changes', () { + expect( + buildAutoGlanceView(_snapshot(txCount: 4), now: _now).fingerprint, + isNot(buildAutoGlanceView(_snapshot(), now: _now).fingerprint), + ); + }); + }); +} diff --git a/test/services/auto/car_map_channel_test.dart b/test/services/auto/car_map_channel_test.dart new file mode 100644 index 00000000..de41ac16 --- /dev/null +++ b/test/services/auto/car_map_channel_test.dart @@ -0,0 +1,376 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/auto/car_map_channel.dart'; +import 'package:mesh_mapper/widgets/map_widget.dart' show gpsMarkerFacesHeading; +import 'package:mesh_mapper/services/watch/watch_models.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('meshmapper/car_map_test'); + late List calls; + + setUp(() { + calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return true; + }); + }); + + CarMapChannel build() => CarMapChannel(channel: channel); + + test('sends the camera through to native', () async { + await build().setCamera(lat: 47.6, lon: -122.3, bearing: 90); + expect(calls.single.method, 'setCamera'); + expect(calls.single.arguments['lat'], 47.6); + expect(calls.single.arguments['lon'], -122.3); + expect(calls.single.arguments['bearing'], 90); + }); + + // GPS ticks at 1–2 Hz and jitters below a metre. Forwarding every one would + // be a platform round-trip per tick for a camera move no driver can see. + test('sub-metre jitter does not reach native', () async { + final map = build(); + await map.setCamera(lat: 47.600000, lon: -122.300000); + await map.setCamera(lat: 47.600001, lon: -122.300001); + expect(calls, hasLength(1)); + }); + + test('real movement does reach native', () async { + final map = build(); + await map.setCamera(lat: 47.6000, lon: -122.3000); + await map.setCamera(lat: 47.6010, lon: -122.3000); + expect(calls, hasLength(2)); + }); + + test('a style change is sent', () async { + final map = build(); + await map.setStyle('https://tiles.openfreemap.org/styles/dark'); + await map.setStyle('https://tiles.openfreemap.org/styles/bright'); + expect(calls, hasLength(2)); + }); + + test('an empty style is ignored', () async { + await build().setStyle(''); + expect(calls, isEmpty); + }); + + group('settings', () { + CarMapSettings settings({ + bool northUp = true, + bool rotationLocked = false, + String style = 'https://tiles.openfreemap.org/styles/dark', + String marker = 'arrow', + }) => + CarMapSettings.from( + styleUrl: style, + zoneCode: 'sea', + tilesEnabled: true, + gridSize: 300, + colorVisionType: 'none', + opacity: 0.7, + mapAlwaysNorth: northUp, + mapRotationLocked: rotationLocked, + markerStyle: marker, + markerFacesHeading: gpsMarkerFacesHeading(marker), + ); + + // mapAlwaysNorth defaults true, so getting this wrong rotates the car map + // for most people who never asked for it. + test('north-up is honoured', () { + expect(settings(northUp: true).northUp, isTrue); + expect(settings(northUp: false).northUp, isFalse); + }); + + test('the marker style and its rotation rule ride along', () { + expect(settings(marker: 'arrow').markerStyle, 'arrow'); + expect(settings(marker: 'arrow').markerFacesHeading, isTrue); + // Car, bike and boat stay upright on a rotated map. + expect(settings(marker: 'car').markerFacesHeading, isFalse); + }); + + test('a rotation lock also means north-up', () { + expect(settings(northUp: false, rotationLocked: true).northUp, isTrue); + }); + + // The satellite style is an inline style document, not a URL. Anything that + // trims, normalises or url-encodes it on the way through breaks it. + test('an inline style document passes through unmangled', () { + const doc = '{"version":8,"sources":{}}'; + expect(settings(style: doc).styleUrl, doc); + }); + }); + + group('pings', () { + WatchPing ping(String id, double lat, double lon, WatchColor color) => + WatchPing( + id: id, + lat: lat, + lon: lon, + kind: 'tx', + color: color, + at: DateTime.fromMillisecondsSinceEpoch(1760000000000), + ); + + test('one feature per ping, with its resolved colour', () { + final json = jsonDecode(encodePingsGeoJson([ + ping('a', 47.6, -122.3, const WatchColor(1, 0, 0)), + ping('b', 47.7, -122.4, const WatchColor(0, 1, 0)), + ])) as Map; + + expect(json['type'], 'FeatureCollection'); + final features = json['features'] as List; + expect(features, hasLength(2)); + expect((features.first as Map)['properties'], { + 'color': '#ff0000', + 'kind': 'tx', + }); + expect((features.last as Map)['properties'], contains('color')); + }); + + // GeoJSON is lon,lat. Getting it backwards produces a map that looks + // plausible and is wrong. + test('coordinates are lon,lat', () { + final json = jsonDecode( + encodePingsGeoJson( + [ping('a', 47.6, -122.3, const WatchColor(1, 1, 1))]), + ) as Map; + final geometry = + ((json['features'] as List).single as Map)['geometry'] as Map; + expect(geometry['coordinates'], [-122.3, 47.6]); + }); + + test('no pings is an empty collection, not a missing one', () { + final json = jsonDecode(encodePingsGeoJson([])) as Map; + expect(json['features'], isEmpty); + }); + + test('is sent, then deduped', () async { + final map = build(); + final one = [ping('a', 47.6, -122.3, const WatchColor(1, 0, 0))]; + await map.setPings(one); + await map.setPings([...one]); + expect(calls, hasLength(1)); + expect(calls.single.method, 'setPings'); + }); + + test('a new ping is sent', () async { + final map = build(); + await map.setPings([ping('a', 47.6, -122.3, const WatchColor(1, 0, 0))]); + await map.setPings([ + ping('a', 47.6, -122.3, const WatchColor(1, 0, 0)), + ping('b', 47.7, -122.4, const WatchColor(0, 1, 0)), + ]); + expect(calls, hasLength(2)); + }); + }); + + group('timer', () { + final deadline = DateTime.fromMillisecondsSinceEpoch(1760000030000); + + test('deadline and duration cross, nothing per-second', () async { + await build() + .setTimer(endsAt: deadline, durationMs: 30000, argbColor: 0xFFFF0000); + expect(calls.single.method, 'setTimer'); + expect(calls.single.arguments['endsAtMs'], 1760000030000); + expect(calls.single.arguments['durationMs'], 30000); + }); + + // A phase with no deadline must clear the bar, not leave a stuck one. + test('a deadline-less phase clears it', () async { + await build().setTimer(); + expect(calls.single.arguments['endsAtMs'], isNull); + expect(calls.single.arguments['durationMs'], isNull); + }); + + test('an unchanged phase is not re-sent', () async { + final map = build(); + await map.setTimer(endsAt: deadline, durationMs: 30000); + await map.setTimer(endsAt: deadline, durationMs: 30000); + expect(calls, hasLength(1)); + }); + + test('a new cycle is sent', () async { + final map = build(); + await map.setTimer(endsAt: deadline, durationMs: 30000); + await map.setTimer( + endsAt: deadline.add(const Duration(seconds: 30)), + durationMs: 30000, + ); + expect(calls, hasLength(2)); + }); + }); + + // The style is deliberately not deduped on this side: a load is asynchronous + // and can fail, and caching what we sent would remember a failure as a + // success and never retry. Native dedupes on what actually loaded. + test('the style is re-sent every time', () async { + final map = build(); + await map.setStyle('https://tiles.openfreemap.org/styles/dark'); + await map.setStyle('https://tiles.openfreemap.org/styles/dark'); + expect(calls, hasLength(2)); + }); + + group('the position marker', () { + final png = Uint8List.fromList(const [1, 2, 3, 4]); + + test('is sent once, then deduped on the style name', () async { + final map = build(); + await map.setPositionMarker(style: 'arrow', png: png, facesHeading: true); + await map.setPositionMarker(style: 'arrow', png: png, facesHeading: true); + expect(calls, hasLength(1)); + expect(calls.single.method, 'setPositionMarker'); + expect(calls.single.arguments['facesHeading'], isTrue); + }); + + test('a style change re-sends it', () async { + final map = build(); + await map.setPositionMarker(style: 'arrow', png: png, facesHeading: true); + await map.setPositionMarker(style: 'car', png: png, facesHeading: false); + expect(calls, hasLength(2)); + expect(calls.last.arguments['facesHeading'], isFalse); + }); + }); + + group('camera bearing versus marker heading', () { + // The subtle one. In north-up mode the camera bearing is deliberately null + // so the map does not turn, but the marker still has to point along the + // direction of travel. Collapsing these into one field leaves the arrow + // stuck pointing north on the setting most people have on by default. + test('heading survives while bearing is nulled', () async { + await build().setCamera( + lat: 47.6, + lon: -122.3, + bearing: null, + heading: 90, + ); + expect(calls.single.arguments['bearing'], isNull); + expect(calls.single.arguments['heading'], 90); + }); + + test('turning on the spot still moves the marker', () async { + final map = build(); + await map.setCamera(lat: 47.6, lon: -122.3, heading: 0); + await map.setCamera(lat: 47.6, lon: -122.3, heading: 90); + expect(calls, hasLength(2), + reason: 'position deduping must not swallow a heading change'); + }); + }); + + // The channel exists only on Android, and only once the engine has registered + // it. Every other platform must be a silent no-op, not a crash on a GPS tick. + group('coverage', () { + CarMapCoverage? forZone({ + String? zone = 'sea', + int gridSize = 300, + String cvd = 'none', + double opacity = 0.7, + bool tilesEnabled = true, + }) => + CarMapCoverage.forZone( + zoneCode: zone, + gridSize: gridSize, + colorVisionType: cvd, + opacity: opacity, + tilesEnabled: tilesEnabled, + ); + + // The user turns tiles off to stop spending a tethered connection. + // Honouring that only on the phone spends it anyway, where they cannot see. + test('tiles switched off means nothing to draw', () { + expect(forZone(tilesEnabled: false), isNull); + }); + + test('the tile url carries the zone and grid size', () { + final coverage = forZone(zone: 'SEA', gridSize: 500)!; + expect( + coverage.tileUrl, + 'https://sea.meshmapper.net/vector_tile.php' + '?z={z}&x={x}&y={y}&gsize=500', + ); + }); + + // Zone and grid size live in the URL, so either changing is a different + // source — not a restyled one. The dedupe below has to see that. + test('a grid change is a different overlay', () { + expect(forZone(gridSize: 300), isNot(forZone(gridSize: 500))); + }); + + test('a zone change is a different overlay', () { + expect(forZone(zone: 'sea'), isNot(forZone(zone: 'pdx'))); + }); + + test('there is nothing to draw before a zone is known', () { + expect(forZone(zone: null), isNull); + expect(forZone(zone: ''), isNull); + }); + + test('a fully transparent overlay is nothing to draw', () { + expect(forZone(opacity: 0), isNull); + }); + + // Dart owns the palette; native must never hold a second copy that drifts. + // What crosses the channel is a finished MapLibre expression. + test('the palette crosses as a MapLibre match expression', () { + final coverage = forZone()!; + final fill = jsonDecode(coverage.fillColor) as List; + expect(fill.first, 'match'); + expect(fill[1], ['get', 'st']); + expect(fill, contains('#1e7e34'), reason: 'st 1 green, from the palette'); + expect( + jsonDecode(coverage.outlineColor) as List, + contains('#14522d'), + reason: 'st 1 border, from the palette', + ); + }); + + test('a colour-vision mode changes the expression', () { + expect(forZone(cvd: 'none')!.fillColor, + isNot(forZone(cvd: 'protanopia')!.fillColor)); + }); + + test('is sent to native', () async { + await build().setCoverage(forZone()); + expect(calls.single.method, 'setCoverage'); + expect(calls.single.arguments['tileUrl'], contains('sea.meshmapper.net')); + expect(calls.single.arguments['opacity'], 0.7); + }); + + // Re-applying rebuilds the layer, which restarts every tile request — + // expensive on a tether and visible as a flash of empty map. + test('an unchanged overlay is not re-sent', () async { + final map = build(); + await map.setCoverage(forZone()); + await map.setCoverage(forZone()); + expect(calls, hasLength(1)); + }); + + test('a changed overlay is re-sent', () async { + final map = build(); + await map.setCoverage(forZone(gridSize: 300)); + await map.setCoverage(forZone(gridSize: 500)); + expect(calls, hasLength(2)); + }); + + test('clearing is sent once, then deduped', () async { + final map = build(); + await map.setCoverage(null); + await map.setCoverage(null); + expect(calls, hasLength(1)); + expect(calls.single.arguments, isEmpty); + }); + }); + + test('a missing native side is not fatal', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + await expectLater( + build().setCamera(lat: 47.6, lon: -122.3), + completes, + ); + }); +} diff --git a/test/services/bluetooth/ble_connect_retry_policy_test.dart b/test/services/bluetooth/ble_connect_retry_policy_test.dart new file mode 100644 index 00000000..590956ef --- /dev/null +++ b/test/services/bluetooth/ble_connect_retry_policy_test.dart @@ -0,0 +1,177 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/bluetooth/ble_connect_retry_policy.dart'; + +/// #500: connect() only treated Android error 133 as retryable, so the 15s +/// GATT connect timeout, transient GATT errors and service discovery failures +/// all rethrew on attempt 1 of 3, and a handshake that died right after the +/// transport connected aborted the whole connection. Users had to tap Connect +/// 4-7 times to do the work the retry loop was supposed to do. +void main() { + group('classifyBleConnectFailure', () { + BleConnectFailureAction classify(String error, + {bool isIos = false, int attempt = 1, int maxRetries = 3}) { + return classifyBleConnectFailure( + errorString: error, + isIos: isIos, + attempt: attempt, + maxRetries: maxRetries, + ); + } + + test('retries the 15s GATT connect timeout', () { + expect( + classify( + 'FlutterBluePlusException | connect | fbp-code: 1 | Timed out after 15s'), + BleConnectFailureAction.retry, + ); + }); + + test('still retries Android error 133', () { + expect( + classify( + 'FlutterBluePlusException | connect | android-code: 133 | GATT_ERROR'), + BleConnectFailureAction.retry, + ); + }); + + test('retries transient GATT errors from setNotifyValue', () { + expect( + classify( + 'FlutterBluePlusException | setNotifyValue | android-code: 28 | UNKNOWN_GATT_ERROR', + attempt: 2), + BleConnectFailureAction.retry, + ); + }); + + test('retries a device drop during setup', () { + expect( + classify( + 'FlutterBluePlusException | discoverServices | fbp-code: 6 | Device is disconnected'), + BleConnectFailureAction.retry, + ); + }); + + test('retries service discovery failures', () { + expect( + classify('Exception: MeshCore service not found'), + BleConnectFailureAction.retry, + ); + }); + + test('fails once attempts are exhausted', () { + expect( + classify( + 'FlutterBluePlusException | connect | fbp-code: 1 | Timed out after 15s', + attempt: 3), + BleConnectFailureAction.fail, + ); + }); + + test('aborts on iOS bond error apple-code 14 without burning retries', () { + expect( + classify( + 'FlutterBluePlusException | connect | apple-code: 14 | Peer removed pairing information', + isIos: true), + BleConnectFailureAction.abortForBondError, + ); + }); + + test('aborts on iOS bond error apple-code 15', () { + expect( + classify( + 'FlutterBluePlusException | connect | apple-code: 15 | Failed to encrypt the connection', + isIos: true), + BleConnectFailureAction.abortForBondError, + ); + }); + + test('bond error strings on Android are plain retryable errors', () { + expect( + classify('Exception: apple-code: 14', isIos: false), + BleConnectFailureAction.retry, + ); + }); + }); + + group('shouldRerunConnectionWorkflow', () { + bool rerun({ + bool transportConnected = true, + Duration? sinceTransportConnect = const Duration(seconds: 3), + String error = 'TimeoutException: Device query timed out', + bool isAutoReconnecting = false, + bool userRequestedDisconnect = false, + bool alreadyRerun = false, + }) { + return shouldRerunConnectionWorkflow( + transportConnected: transportConnected, + sinceTransportConnect: sinceTransportConnect, + errorString: error, + isAutoReconnecting: isAutoReconnecting, + userRequestedDisconnect: userRequestedDisconnect, + alreadyRerun: alreadyRerun, + ); + } + + test('reruns when the handshake dies right after transport connect', () { + expect(rerun(), isTrue); + }); + + test('covers a 15s writeCharacteristic timeout inside the window', () { + expect( + rerun( + sinceTransportConnect: const Duration(seconds: 16), + error: + 'FlutterBluePlusException | writeCharacteristic | fbp-code: 1 | Timed out after 15s', + ), + isTrue, + ); + }); + + test('does not rerun long after the transport connected', () { + expect( + rerun(sinceTransportConnect: const Duration(seconds: 30)), + isFalse, + ); + }); + + test('does not rerun when the transport never connected', () { + expect( + rerun(transportConnected: false, sinceTransportConnect: null), + isFalse, + ); + }); + + test('never reruns a server auth rejection', () { + expect( + rerun(error: 'Exception: AUTH_FAILED:zone_full:Zone is full'), + isFalse, + ); + }); + + test('never reruns a disposed connection', () { + expect( + rerun(error: 'Exception: Connection instance has been disposed'), + isFalse, + ); + }); + + test('reruns only once', () { + expect(rerun(alreadyRerun: true), isFalse); + }); + + test('defers to the auto-reconnect machinery', () { + expect(rerun(isAutoReconnecting: true), isFalse); + }); + + test('does not rerun after a user-requested disconnect', () { + expect(rerun(userRequestedDisconnect: true), isFalse); + }); + + test('never reruns an attempt superseded by a newer connect', () { + expect( + rerun(error: 'Exception: Connection attempt superseded'), + isFalse, + ); + }); + }); +} diff --git a/test/services/debug_file_logger_scrub_test.dart b/test/services/debug_file_logger_scrub_test.dart new file mode 100644 index 00000000..33fcf347 --- /dev/null +++ b/test/services/debug_file_logger_scrub_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/debug_file_logger.dart'; + +/// Debug log FILES are uploaded verbatim with bug reports and debug logging is +/// on in release builds, so the file writer scrubs credential shapes even when +/// a caller was careless. +void main() { + test('redacts a bearer token', () { + expect( + DebugFileLogger.scrubSecrets( + 'POST with Authorization: Bearer ${'a' * 64}'), + 'POST with Authorization: Bearer ', + ); + }); + + test('redacts a token JSON field', () { + expect( + DebugFileLogger.scrubSecrets('body {"ok":true,"token":"${'b' * 64}"}'), + contains('"token":""'), + ); + expect( + DebugFileLogger.scrubSecrets('body {"ok":true,"token":"${'b' * 64}"}'), + isNot(contains('b' * 64)), + ); + }); + + test('redacts code, code_verifier and code_challenge query parameters', () { + final scrubbed = DebugFileLogger.scrubSecrets( + 'callback?code=${'c' * 64}&code_verifier=${'d' * 43}&state=xyz'); + expect(scrubbed, contains('code=')); + expect(scrubbed, contains('code_verifier=')); + expect(scrubbed, isNot(contains('c' * 64))); + expect(scrubbed, isNot(contains('d' * 43))); + }); + + test('redacts an unquoted token in a rendered map', () { + // Dart's Map.toString() has neither quotes nor `=`, and + // debug_submit_service.dart logs a decoded response body exactly this way + // ("Full response: $data"). The quoted-JSON pattern never sees that shape. + final scrubbed = DebugFileLogger.scrubSecrets( + 'Full response: {ok: true, token: ${'f' * 64}}'); + expect(scrubbed, contains('token: ')); + expect(scrubbed, isNot(contains('f' * 64))); + }); + + test('redacts the X-MM-App-Token header shape', () { + final scrubbed = + DebugFileLogger.scrubSecrets('X-MM-App-Token: ${'a1b2c3d4' * 8}'); + expect(scrubbed, 'X-MM-App-Token: '); + }); + + test('leaves a short token value alone', () { + expect(DebugFileLogger.scrubSecrets('token: abc'), 'token: abc'); + }); + + test('leaves a wire tag untouched', () { + // MM:<10 base64url> is not a secret and shows up constantly in TX logs. + const line = '[PING] on-air body MM:YVNPAr5OIw'; + expect(DebugFileLogger.scrubSecrets(line), line); + }); + + test('leaves ordinary log lines untouched', () { + const line = '[CONN] Frame received (7 bytes): 13 00 80 00 00 00'; + expect(DebugFileLogger.scrubSecrets(line), line); + }); + + test('does not eat short values that merely look similar', () { + expect(DebugFileLogger.scrubSecrets('mode=1&code=7'), 'mode=1&code=7'); + }); +} diff --git a/test/services/external_commands/external_session_commands_test.dart b/test/services/external_commands/external_session_commands_test.dart new file mode 100644 index 00000000..24a074ce --- /dev/null +++ b/test/services/external_commands/external_session_commands_test.dart @@ -0,0 +1,447 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/external_commands/external_command_models.dart'; +import 'package:mesh_mapper/services/external_commands/external_session_commands.dart'; + +ExternalSessionCommand command({ + ExternalSessionCommandKind kind = ExternalSessionCommandKind.startSession, + ExternalCommandSource source = ExternalCommandSource.siri, + String? mode = 'passive', + String? sessionId, + DateTime? issuedAt, + DateTime? expiresAt, +}) => + ExternalSessionCommand( + id: 'command-1', + source: source, + kind: kind, + issuedAt: issuedAt ?? DateTime.fromMillisecondsSinceEpoch(100000), + expiresAt: expiresAt, + mode: mode, + sessionId: sessionId, + ); + +void main() { + final now = DateTime.fromMillisecondsSinceEpoch(100000); + + group('wire decoding', () { + test('accepts only a complete Siri payload', () { + final decoded = ExternalSessionCommand.fromMap( + { + 'id': 'abc', + 'source': 'siri', + 'kind': 'startSession', + 'issuedAtMs': 100000, + 'mode': 'hybrid', + 'sessionId': null, + }, + expectedSource: ExternalCommandSource.siri, + ); + + expect(decoded, isNotNull); + expect(decoded!.kind, ExternalSessionCommandKind.startSession); + expect(decoded.mode, 'hybrid'); + }); + + test('rejects another transport and unknown commands', () { + expect( + ExternalSessionCommand.fromMap( + { + 'id': 'abc', + 'source': 'watch', + 'kind': 'startSession', + 'issuedAtMs': 100000, + }, + expectedSource: ExternalCommandSource.siri, + ), + isNull, + ); + expect( + ExternalSessionCommand.fromMap( + { + 'id': 'abc', + 'source': 'siri', + 'kind': 'traceRepeater', + 'issuedAtMs': 100000, + }, + expectedSource: ExternalCommandSource.siri, + ), + isNull, + ); + }); + }); + + group('transition admission', () { + ExternalCommandAdmission resolve( + ExternalSessionCommand value, { + bool active = false, + bool starting = false, + String currentMode = 'hybrid', + String currentSessionId = 'session-a', + String currentModeLabel = 'Hybrid', + }) => + resolveExternalSessionTransition( + command: value, + isSessionActive: active, + isSessionStarting: starting, + currentMode: currentMode, + currentSessionId: currentSessionId, + currentModeLabel: currentModeLabel, + now: now, + ); + + test('a running session is named the way the app names it, not the wire', + () { + final admission = resolve( + command(), + active: true, + currentMode: 'targeted', + currentModeLabel: 'Trace', + ); + + expect( + admission.reason?.compactText, + 'MeshMapper is already running in Trace mode.', + ); + }); + + test('Siri defaults an unqualified Start to Passive Discovery', () { + final admission = resolve(command(mode: null)); + + expect(admission.disposition, ExternalCommandDisposition.admitted); + expect(admission.mode, ExternalSessionMode.passive); + }); + + test('all three general modes are admitted and Trace is refused', () { + for (final mode in ['active', 'passive', 'hybrid']) { + expect( + resolve(command(mode: mode)).mode?.name, + mode, + ); + } + final trace = resolve(command(mode: 'targeted')); + expect(trace.disposition, ExternalCommandDisposition.refused); + expect(trace.reason?.compactText, 'Unsupported start mode'); + }); + + test('voice refusal expands Passive Only with mode and next step', () { + final requested = command(mode: 'hybrid'); + final completion = externalCommandCompletionForVoice( + command: requested, + completion: const ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: ExternalCommandReason.passiveOnly, + sessionId: 'session-1', + mode: ExternalSessionMode.hybrid, + ), + ); + + expect( + completion.message?.compactText, + "Hybrid mode isn't available in this region. Start Passive Discovery instead.", + ); + expect(completion.sessionId, 'session-1'); + expect(completion.mode, ExternalSessionMode.hybrid); + }); + + test('voice presentation expands common compact admission labels', () { + final requested = command(mode: 'active'); + final expected = { + ExternalCommandReason.notConnected: + "MeshMapper isn't connected. Reconnect it, then try again.", + ExternalCommandReason.notConnectedToDevice: + "MeshMapper isn't connected. Reconnect it, then try again.", + ExternalCommandReason.stillStopping: + 'MeshMapper is still stopping. Try again shortly.', + ExternalCommandReason.traceSessionActive: + 'Stop the active Trace session before starting Active mode.', + ExternalCommandReason.alreadyStarting: + 'MeshMapper is already starting a session.', + ExternalCommandReason.coolingDown: + 'MeshMapper is cooling down. Try again shortly.', + ExternalCommandReason.waitFiveSeconds: + 'MeshMapper is cooling down. Try again shortly.', + ExternalCommandReason.waitFifteenSeconds: + 'MeshMapper is cooling down. Try again shortly.', + ExternalCommandReason.waitingForGpsLock: + 'MeshMapper is waiting for a GPS fix. Try again shortly.', + ExternalCommandReason.selectAntennaOption: + 'Select an antenna option in MeshMapper, then try again.', + ExternalCommandReason.selectAntennaOptionBeforePinging: + 'Select an antenna option in MeshMapper, then try again.', + ExternalCommandReason.selectPowerLevel: + 'Select a power level in MeshMapper, then try again.', + ExternalCommandReason.selectPowerLevelUnknownDevice: + 'Select a power level in MeshMapper, then try again.', + ExternalCommandReason.offlineMode: + "Active mode isn't available in Offline Mode. Start Passive Discovery instead.", + ExternalCommandReason.passiveOnly: + "Active mode isn't available in this region. Start Passive Discovery instead.", + ExternalCommandReason.zoneAtCapacity: + "Active mode isn't available in this region. Start Passive Discovery instead.", + ExternalCommandReason.floodTrafficOff: + 'Active mode requires Flood Traffic. Enable it or start Passive Discovery.', + ExternalCommandReason.pingInProgress: + 'A ping is already in progress. Try again when it finishes.', + ExternalCommandReason.listeningForPingResponse: + 'MeshMapper is listening for a response. Try again shortly.', + ExternalCommandReason.gpsDataStale: + 'MeshMapper needs a current GPS position. Let location refresh, then retry.', + ExternalCommandReason.gpsAccuracyLow: + 'GPS accuracy is too low. Try again when it improves.', + ExternalCommandReason.anotherOperationInProgress: + 'Another radio operation is in progress. Try again shortly.', + ExternalCommandReason.stillStartingTryStopAgain: + 'MeshMapper is still starting. Try Stop again shortly.', + ExternalCommandReason.sessionAlreadyEnded: + 'That session already ended. There is nothing to stop.', + ExternalCommandReason.couldNotStart: + "MeshMapper couldn't start Active mode.", + ExternalCommandReason.couldNotStop: + "MeshMapper couldn't stop. Check the app for details.", + ExternalCommandReason.pingFailed: + "MeshMapper couldn't send the manual ping. Open the app for details.", + ExternalCommandReason.appClosing: + 'MeshMapper is closing. Open it and try again.', + }; + + for (final entry in expected.entries) { + final completion = externalCommandCompletionForVoice( + command: requested, + completion: ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: entry.key, + ), + ); + expect( + completion.message?.compactText, + entry.value, + reason: entry.key.compactText, + ); + } + }); + + test('voice presentation preserves already complete responses', () { + final requested = command(mode: 'passive'); + const original = ExternalCommandCompletion( + success: true, + disposition: ExternalCommandDisposition.admitted, + message: ExternalCommandReason.other( + 'MeshMapper started in Passive Discovery mode.', + ), + ); + + expect( + externalCommandCompletionForVoice( + command: requested, + completion: original, + ), + same(original), + ); + }); + + test('duplicate Start and idle Stop are first-class no-ops', () { + final duplicate = resolve(command(), active: true); + final duplicateStarting = resolve(command(), starting: true); + final idleStop = resolve(command( + kind: ExternalSessionCommandKind.stopSession, + mode: null, + )); + final stoppingWhileStarting = resolve( + command( + kind: ExternalSessionCommandKind.stopSession, + mode: null, + ), + starting: true, + ); + + expect(duplicate.disposition, ExternalCommandDisposition.noOp); + expect(duplicate.reason?.compactText, contains('already running')); + expect(duplicateStarting.disposition, ExternalCommandDisposition.noOp); + expect( + duplicateStarting.reason?.compactText, + contains('already starting'), + ); + expect(idleStop.disposition, ExternalCommandDisposition.noOp); + expect(idleStop.reason?.compactText, contains("isn't")); + expect( + stoppingWhileStarting.disposition, + ExternalCommandDisposition.refused, + ); + expect( + stoppingWhileStarting.reason?.compactText, + 'Still starting, try Stop again', + ); + }); + + test('Stop cannot terminate a newer session', () { + final admission = resolve( + command( + kind: ExternalSessionCommandKind.stopSession, + mode: null, + sessionId: 'session-a', + ), + active: true, + currentSessionId: 'session-b', + ); + + expect(admission.disposition, ExternalCommandDisposition.refused); + expect(admission.reason?.compactText, 'That session already ended'); + + final sameSession = resolve( + command( + kind: ExternalSessionCommandKind.stopSession, + mode: null, + sessionId: 'session-b', + ), + active: true, + currentSessionId: 'session-b', + ); + final olderWatch = resolve( + command( + kind: ExternalSessionCommandKind.stopSession, + mode: null, + ), + active: true, + currentSessionId: 'session-b', + ); + expect(sameSession.disposition, ExternalCommandDisposition.admitted); + expect(olderWatch.disposition, ExternalCommandDisposition.admitted); + }); + + test('Stop is age exempt but Start and Manual Ping expire', () { + final stale = now.subtract(const Duration(seconds: 31)); + final start = resolve(command(issuedAt: stale)); + final ping = resolve(command( + kind: ExternalSessionCommandKind.manualPing, + mode: null, + issuedAt: stale, + )); + final stop = resolve( + command( + kind: ExternalSessionCommandKind.stopSession, + mode: null, + sessionId: 'session-a', + issuedAt: stale, + ), + active: true, + ); + + expect(start.reason?.compactText, externalCommandExpiredReason); + expect(ping.reason?.compactText, externalCommandExpiredReason); + expect(stop.disposition, ExternalCommandDisposition.admitted); + }); + + test('future timestamps beyond tolerance are refused', () { + final admission = resolve( + command(issuedAt: now.add(const Duration(seconds: 6))), + ); + + expect(admission.disposition, ExternalCommandDisposition.refused); + }); + }); + + group("the issuing surface's deadline", () { + test('a passed deadline refuses a Start that is still young enough', () { + // Siri gives up long before the shared 30-second age rule would. + final admission = resolveExternalSessionTransition( + command: command(expiresAt: now.subtract(const Duration(seconds: 1))), + isSessionActive: false, + isSessionStarting: false, + currentMode: 'passive', + currentSessionId: 'session-a', + currentModeLabel: 'Passive', + now: now, + ); + + expect(admission.disposition, ExternalCommandDisposition.refused); + expect(admission.reason?.compactText, externalCommandExpiredReason); + }); + + test('Stop stays exempt, because stopping is the safe direction', () { + final admission = resolveExternalSessionTransition( + command: command( + kind: ExternalSessionCommandKind.stopSession, + mode: null, + expiresAt: now.subtract(const Duration(seconds: 30)), + ), + isSessionActive: true, + isSessionStarting: false, + currentMode: 'passive', + currentSessionId: 'session-a', + currentModeLabel: 'Passive', + now: now, + ); + + expect(admission.disposition, ExternalCommandDisposition.admitted); + }); + + test('a surface that names no deadline is unaffected', () { + final admission = resolveExternalSessionTransition( + command: command(), + isSessionActive: false, + isSessionStarting: false, + currentMode: 'passive', + currentSessionId: 'session-a', + currentModeLabel: 'Passive', + now: now, + ); + + expect(admission.disposition, ExternalCommandDisposition.admitted); + }); + + test('the commit gate closes before the deadline, not after it', () { + // The awaited session check runs between admission and the radio. A + // deadline with less than the margin left cannot be met by work that has + // not started yet, so committing then would still land after Siri gave up. + expect( + externalCommandCannotCommit( + now.add(const Duration(seconds: 5)), + now: now, + ), + isFalse, + ); + expect( + externalCommandCannotCommit( + now.add(externalCommandCommitMargin), + now: now, + ), + isTrue, + ); + expect( + externalCommandCannotCommit( + now.subtract(const Duration(seconds: 1)), + now: now, + ), + isTrue, + ); + }); + + test('no deadline means nothing to miss', () { + expect(externalCommandCannotCommit(null, now: now), isFalse); + expect(externalCommandDeadlineRefusal(null, now: now), isNull); + }); + + test('an expired command gets a spoken reason, not the compact label', () { + final expired = command( + expiresAt: now.subtract(const Duration(seconds: 1)), + ); + final spoken = externalCommandCompletionForVoice( + command: expired, + completion: const ExternalCommandCompletion( + success: false, + disposition: ExternalCommandDisposition.refused, + message: ExternalCommandReason.commandExpired, + ), + ); + + expect(spoken.message?.compactText, externalCommandExpiredVoiceMessage); + expect( + externalCommandExpiredVoiceMessage, + isNot(externalCommandExpiredReason), + reason: 'the watch keeps the terse label; voice gets the next step', + ); + }); + }); +} diff --git a/test/services/gps_service_anchor_test.dart b/test/services/gps_service_anchor_test.dart new file mode 100644 index 00000000..94230f34 --- /dev/null +++ b/test/services/gps_service_anchor_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:mesh_mapper/services/gps_service.dart'; + +/// #501: the map's distance-to-previous-ping readout read the TX skip anchor, +/// which only TX pings update. In Trace and Passive modes it stayed anchored +/// to the last TX (typically the session start) and showed kilometers while +/// the real previous trace was 100 m away. The display now follows a separate +/// any-ping anchor; the TX skip anchor is untouched so feeding discovery and +/// trace positions into the readout cannot skip Hybrid TX pings. +Position _pos(double lat, double lon) => Position( + latitude: lat, + longitude: lon, + timestamp: DateTime.now(), + accuracy: 5.0, + altitude: 0.0, + altitudeAccuracy: 1.0, + heading: 0.0, + headingAccuracy: 1.0, + speed: 0.0, + speedAccuracy: 1.0, + ); + +void main() { + test('no ping of any type yet reports infinity', () { + final gps = GpsService(); + expect(gps.distanceFromLastActivity(_pos(45.0, -75.0)), double.infinity); + }); + + test('a TX ping moves both the display and the skip anchor', () { + final gps = GpsService(); + final p0 = _pos(45.0, -75.0); + gps.markPingPosition(p0); + expect(gps.distanceFromLastActivity(p0), 0.0); + expect(gps.distanceFromLastPing(p0), 0.0); + }); + + test('a discovery or trace moves the display anchor only', () { + final gps = GpsService(); + final txPos = _pos(45.0, -75.0); + final tracePos = _pos(45.01, -75.0); // ~1.1 km north + gps.markPingPosition(txPos); + gps.markActivityPosition(tracePos); + + // Readout follows the trace. + expect(gps.distanceFromLastActivity(tracePos), 0.0); + + // TX skip logic still measures from the last TX ping. + expect(gps.distanceFromLastPing(tracePos), greaterThan(1000)); + expect(gps.canPingAtPosition(tracePos), isTrue); + expect(gps.canPingAtPosition(txPos), isFalse); + }); +} diff --git a/test/services/link_decision_test.dart b/test/services/link_decision_test.dart new file mode 100644 index 00000000..93042354 --- /dev/null +++ b/test/services/link_decision_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/link_decision.dart'; + +/// The "strictly non-fatal" contract in table form. Exactly ONE combination +/// prompts; every other row is a silent skip. +void main() { + const goodPubkey = + 'AABBCCDDEEFF00112233445566778899AABBCCDDEEFF001122334455667788AA'; + + LinkFlowDecision call({ + bool loggedIn = true, + bool offlineMode = false, + bool anonymousMode = false, + bool autoPingActive = false, + bool autoReconnecting = false, + String? pubkey = goodPubkey, + bool declined = false, + bool linked = false, + bool signUnsupported = false, + bool promptedThisAppSession = false, + }) => + decideLinkFlow( + loggedIn: loggedIn, + offlineMode: offlineMode, + anonymousMode: anonymousMode, + autoPingActive: autoPingActive, + autoReconnecting: autoReconnecting, + pubkey: pubkey, + declined: declined, + linked: linked, + signUnsupported: signUnsupported, + promptedThisAppSession: promptedThisAppSession, + ); + + test('the all-clear row prompts', () { + expect(call(), LinkFlowDecision.prompt); + }); + + final skipRows = { + 'not signed in': () => call(loggedIn: false), + 'offline mode': () => call(offlineMode: true), + 'anonymous mode': () => call(anonymousMode: true), + 'auto-ping running': () => call(autoPingActive: true), + 'auto-reconnecting': () => call(autoReconnecting: true), + 'no public key': () => call(pubkey: null), + 'empty public key': () => call(pubkey: ''), + 'declined before': () => call(declined: true), + 'already linked': () => call(linked: true), + 'firmware cannot sign': () => call(signUnsupported: true), + 'already prompted this app session': () => + call(promptedThisAppSession: true), + }; + + skipRows.forEach((name, invoke) { + test('skips when $name', () { + expect(invoke(), LinkFlowDecision.skip); + }); + }); + + test('several blockers at once still skip', () { + expect( + call(loggedIn: false, offlineMode: true, linked: true), + LinkFlowDecision.skip, + ); + }); + + test('a lowercase pubkey is still acceptable input', () { + expect(call(pubkey: goodPubkey.toLowerCase()), LinkFlowDecision.prompt); + }); +} diff --git a/test/services/live_activity/live_activity_models_test.dart b/test/services/live_activity/live_activity_models_test.dart new file mode 100644 index 00000000..1c9d07b5 --- /dev/null +++ b/test/services/live_activity/live_activity_models_test.dart @@ -0,0 +1,128 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/live_activity/live_activity_models.dart'; + +void main() { + test('serializes the compact ActivityKit snapshot contract', () { + final updatedAt = DateTime.utc(2026, 7, 14, 18); + final phaseEndsAt = updatedAt.add(const Duration(seconds: 7)); + final snapshot = LiveActivitySnapshot( + sessionId: 'session-1', + mode: 'Hybrid', + phase: LiveActivityPhase.listening, + phaseTitle: 'Listening…', + phaseDetail: 'Waiting for repeater echoes', + phaseEndsAt: phaseEndsAt, + isConnected: true, + zoneCode: 'JKG', + txCount: 42, + rxCount: 318, + discoveryCount: 8, + traceCount: 0, + queueSize: 2, + repeaters: const [ + LiveActivityRepeater( + id: 'A6', + name: 'Huskvarna', + snr: 12.4, + ), + ], + totalHeardCount: 3, + repeatersAreCurrent: true, + updatedAt: updatedAt, + ); + + expect(snapshot.toMap(), { + 'sessionId': 'session-1', + 'mode': 'Hybrid', + 'phase': 'listening', + 'phaseTitle': 'Listening…', + 'phaseDetail': 'Waiting for repeater echoes', + 'phaseEndsAt': phaseEndsAt.millisecondsSinceEpoch, + 'isConnected': true, + 'zoneCode': 'JKG', + 'showRepeaterNames': true, + 'txCount': 42, + 'rxCount': 318, + 'discoveryCount': 8, + 'traceCount': 0, + 'queueSize': 2, + 'repeaters': [ + { + 'id': 'A6', + 'name': 'Huskvarna', + 'snr': 12.4, + }, + ], + 'totalHeardCount': 3, + 'repeatersAreCurrent': true, + 'updatedAt': updatedAt.millisecondsSinceEpoch, + }); + }); + + test('urgency key changes for phase deadlines but not counters', () { + final now = DateTime.utc(2026, 7, 14, 18); + + LiveActivitySnapshot make({ + int rxCount = 1, + DateTime? phaseEndsAt, + }) { + return LiveActivitySnapshot( + sessionId: 'session-1', + mode: 'Active', + phase: LiveActivityPhase.waiting, + phaseTitle: 'Next ping', + phaseEndsAt: phaseEndsAt ?? now.add(const Duration(seconds: 30)), + isConnected: true, + txCount: 1, + rxCount: rxCount, + discoveryCount: 0, + traceCount: 0, + queueSize: 0, + repeaters: const [], + totalHeardCount: 0, + repeatersAreCurrent: false, + updatedAt: now, + ); + } + + expect(make(rxCount: 1).urgencyKey, make(rxCount: 2).urgencyKey); + expect( + make().urgencyKey, + isNot(make(phaseEndsAt: now.add(const Duration(seconds: 15))).urgencyKey), + ); + + // A deadline that shifts by milliseconds is not news, and must not buy a + // trip past the non-urgent throttle. The phone's powerlog measured 720 + // Live Activity updates across a 90-minute walk — one every 7.5 s, 116 of + // them less than a second apart — against Apple Fitness's 2 in the same + // window, because this key carried the deadline at millisecond resolution. + // + // Nothing visible is lost: the widget renders the deadline through + // `Text(timerInterval:)` and `ProgressView(timerInterval:)`, which show + // whole seconds. + expect( + make(phaseEndsAt: now).urgencyKey, + make(phaseEndsAt: now.add(const Duration(milliseconds: 120))).urgencyKey, + ); + // Rounding, not truncation, so the boundary is not a cliff either way. + expect( + make(phaseEndsAt: now).urgencyKey, + make(phaseEndsAt: now.subtract(const Duration(milliseconds: 120))) + .urgencyKey, + ); + // A whole second still counts as news. + expect( + make(phaseEndsAt: now).urgencyKey, + isNot(make(phaseEndsAt: now.add(const Duration(seconds: 1))).urgencyKey), + ); + }); + + test('normalizes non-finite repeater values before encoding', () { + const repeater = LiveActivityRepeater( + id: 'A6', + snr: double.nan, + ); + + expect(repeater.toMap()['snr'], 0.0); + }); +} diff --git a/test/services/live_activity/live_activity_service_test.dart b/test/services/live_activity/live_activity_service_test.dart new file mode 100644 index 00000000..8b2bbcfd --- /dev/null +++ b/test/services/live_activity/live_activity_service_test.dart @@ -0,0 +1,276 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/live_activity/live_activity_models.dart'; +import 'package:mesh_mapper/services/live_activity/live_activity_service.dart'; + +LiveActivitySnapshot _snapshot({ + LiveActivityPhase phase = LiveActivityPhase.listeningDiscovery, + int rxCount = 2, +}) => + LiveActivitySnapshot( + sessionId: 'session-1', + mode: 'Passive', + phase: phase, + phaseTitle: 'Listening…', + isConnected: true, + txCount: 0, + rxCount: rxCount, + discoveryCount: 1, + traceCount: 0, + queueSize: 0, + repeaters: const [], + totalHeardCount: 0, + repeatersAreCurrent: true, + updatedAt: DateTime.utc(2026, 8, 13), + ); + +/// The cheap projection the service preflights on, derived from a built +/// snapshot so a test reads the same way the provider's builder does. +String _preflightKey(LiveActivitySnapshot snapshot) => + LiveActivitySnapshot.buildPreflightUrgencyKey( + sessionId: snapshot.sessionId, + mode: snapshot.mode, + phase: snapshot.phase, + phaseTitle: snapshot.phaseTitle, + phaseDetail: snapshot.phaseDetail, + phaseEndsAt: snapshot.phaseEndsAt, + phaseDurationMs: snapshot.phaseDurationMs, + isConnected: snapshot.isConnected, + zoneCode: snapshot.zoneCode, + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('unavailable Live Activities retry within the same session', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const channel = MethodChannel('meshmapper/live_activity_retry_test'); + var syncCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method != 'sync') return null; + syncCalls++; + return syncCalls > 1; + }); + final service = LiveActivityService( + channel: channel, + unavailableRetryDelay: const Duration(milliseconds: 20), + ); + addTearDown(() { + service.dispose(); + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + service.schedule( + _snapshot, + urgencyKeyBuilder: () => _preflightKey(_snapshot()), + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 80)); + + expect(syncCalls, 2, + reason: 'enabling the feature must not require a new session ID'); + }); + + test('counter churn waits out the non-urgent interval', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const channel = MethodChannel('meshmapper/live_activity_throttle_test'); + final phases = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method != 'sync') return null; + phases.add((call.arguments as Map)['phase'] as String); + return true; + }); + final service = LiveActivityService( + channel: channel, + minimumNonUrgentInterval: const Duration(seconds: 30), + ); + addTearDown(() { + service.dispose(); + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + service.schedule( + _snapshot, + urgencyKeyBuilder: () => _preflightKey(_snapshot()), + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 20)); + + // A changed RX count is real, but nobody is waiting on it. + service.schedule( + () => _snapshot(rxCount: 3), + urgencyKeyBuilder: () => _preflightKey(_snapshot(rxCount: 3)), + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(phases, ['listening_discovery'], + reason: 'a counter change alone must not reach ActivityKit'); + + // A phase change is news, and news is not throttled. + service.schedule( + () => _snapshot(phase: LiveActivityPhase.waitingDiscovery, rxCount: 3), + urgencyKeyBuilder: () => + _preflightKey(_snapshot(phase: LiveActivityPhase.waitingDiscovery)), + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(phases, ['listening_discovery', 'waiting_discovery']); + }); + + test('an unsupported host is asked once, not every 30 s for a session', + () async { + // iOS below 16.2 cannot show a Live Activity and never will while the app + // is running, but it answered the same way as "authorization is off" — so + // it earned the same 30 s retry, forever, on a device that was never going + // to display anything. + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const channel = MethodChannel('meshmapper/live_activity_unsupported_test'); + var syncCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method != 'sync') return null; + syncCalls++; + return LiveActivityService.unsupportedHost; + }); + final service = LiveActivityService( + channel: channel, + unavailableRetryDelay: const Duration(milliseconds: 20), + minimumNonUrgentInterval: const Duration(milliseconds: 20), + ); + addTearDown(() { + service.dispose(); + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + service.schedule( + _snapshot, + urgencyKeyBuilder: () => _preflightKey(_snapshot()), + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 40)); + expect(syncCalls, 1); + + // Everything that would normally re-arm it: the retry it used to schedule, + // and a caller that keeps scheduling regardless. + for (var tick = 0; tick < 4; tick++) { + service.schedule( + () => _snapshot(rxCount: tick), + urgencyKeyBuilder: () => + _preflightKey(_snapshot(phase: LiveActivityPhase.waitingDiscovery)), + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 30)); + } + + expect(syncCalls, 1, + reason: 'a host that cannot host one must be asked exactly once'); + }); + + test('a missing channel also stops for good', () async { + // The other permanent condition: an iOS project generated before this + // feature existed has no such channel, and will not grow one at runtime. + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const channel = MethodChannel('meshmapper/live_activity_missing_test'); + var syncCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'sync') syncCalls++; + throw MissingPluginException('no channel'); + }); + final service = LiveActivityService( + channel: channel, + minimumNonUrgentInterval: const Duration(milliseconds: 20), + ); + addTearDown(() { + service.dispose(); + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + for (var tick = 0; tick < 4; tick++) { + service.schedule( + () => _snapshot(rxCount: tick), + urgencyKeyBuilder: () => _preflightKey(_snapshot(rxCount: tick)), + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 30)); + } + + expect(syncCalls, 1, + reason: 'a host with no channel must be asked exactly once'); + }); + + test('a throttled flush costs nothing to build', () async { + // The 500 ms countdown listenable asks for a flush twice a second for the + // length of a session, and building a snapshot walks the whole TX, RX, + // discovery and trace history to resolve the latest ping colour. The + // throttle has to be decided before any of that, not after. + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const channel = MethodChannel('meshmapper/live_activity_preflight_test'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async => true); + final service = LiveActivityService( + channel: channel, + minimumNonUrgentInterval: const Duration(seconds: 30), + ); + addTearDown(() { + service.dispose(); + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + var builds = 0; + final steadyKey = _preflightKey(_snapshot()); + LiveActivitySnapshot? countedBuild() { + builds++; + return _snapshot(rxCount: builds); + } + + service.schedule( + countedBuild, + urgencyKeyBuilder: () => steadyKey, + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 20)); + expect(builds, 1); + + for (var tick = 0; tick < 5; tick++) { + service.schedule( + countedBuild, + urgencyKeyBuilder: () => steadyKey, + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 5)); + } + + expect(builds, 1, + reason: 'a tick that changes nothing urgent must not build a snapshot'); + + // News still bypasses the floor, and still pays to build. + final news = _snapshot(phase: LiveActivityPhase.waitingDiscovery); + service.schedule( + () { + builds++; + return news; + }, + urgencyKeyBuilder: () => _preflightKey(news), + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(builds, 2, reason: 'a phase change is news and is never deferred'); + }); +} diff --git a/test/services/meshcore/fake_companion_transport.dart b/test/services/meshcore/fake_companion_transport.dart new file mode 100644 index 00000000..35992d4f --- /dev/null +++ b/test/services/meshcore/fake_companion_transport.dart @@ -0,0 +1,62 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:mesh_mapper/models/connection_state.dart'; +import 'package:mesh_mapper/services/bluetooth/bluetooth_service.dart'; +import 'package:mesh_mapper/services/transport/companion_transport.dart'; + +/// In-memory [CompanionTransport] for protocol tests. +/// +/// `MeshCoreConnection` does all of its dispatching off `transport.dataStream` +/// and all of its sending through `transport.write`, so a fake at this seam +/// exercises the real framing code with no radio and no platform channels. +class FakeCompanionTransport implements CompanionTransport { + final StreamController _data = + StreamController.broadcast(); + final StreamController _status = + StreamController.broadcast(); + + /// Every frame the connection has written, oldest first. + final List writes = []; + + /// Set true to make [write] throw, simulating a dead link. + bool failWrites = false; + + @override + Stream get dataStream => _data.stream; + + @override + Stream get connectionStream => _status.stream; + + @override + ConnectionStatus get connectionStatus => ConnectionStatus.connected; + + @override + DiscoveredDevice? get connectedDevice => + const DiscoveredDevice(id: 'fake-transport', name: 'FakeRadio'); + + @override + Future write(Uint8List data) async { + if (failWrites) throw StateError('fake transport link is down'); + writes.add(Uint8List.fromList(data)); + } + + @override + Future disconnect() async {} + + @override + void dispose() { + _data.close(); + _status.close(); + } + + /// Push a frame from the "radio" into the connection's dispatcher. + void emit(List frame) => _data.add(Uint8List.fromList(frame)); + + /// Let the broadcast stream deliver and any awaiting futures resume. + Future settle() => Future.delayed(Duration.zero); + + /// The command byte of the nth write (or -1 when there is no nth write). + int commandAt(int index) => + index < writes.length && writes[index].isNotEmpty ? writes[index][0] : -1; +} diff --git a/test/services/meshcore/sign_flow_test.dart b/test/services/meshcore/sign_flow_test.dart new file mode 100644 index 00000000..58e7c2f3 --- /dev/null +++ b/test/services/meshcore/sign_flow_test.dart @@ -0,0 +1,437 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart' show debugPrint; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/meshcore/connection.dart'; +import 'package:mesh_mapper/services/meshcore/protocol_constants.dart'; +import 'package:mesh_mapper/utils/debug_logger_io.dart'; + +import 'fake_companion_transport.dart'; + +/// CMD_SIGN framing contract (mirrors the portal's meshcore.js, which is what +/// today's 1,475 live companion links were made with): +/// [0x21] -> RESP 19 [0x13][reserved:1][maxSignDataLen:u32 LE] +/// [0x22][<=128 bytes] -> OK [0x00] (one per chunk) +/// [0x23] -> RESP 20 [0x14][sig:64] +void main() { + late FakeCompanionTransport transport; + late MeshCoreConnection connection; + + setUp(() { + transport = FakeCompanionTransport(); + connection = MeshCoreConnection(transport: transport); + }); + + tearDown(() { + connection.dispose(); + transport.dispose(); + }); + + /// RESP_SIGN_START carrying [maxLen]. + List signStartResponse(int maxLen) => [ + ResponseCodes.signStart, + 0x00, // reserved + maxLen & 0xFF, + (maxLen >> 8) & 0xFF, + (maxLen >> 16) & 0xFF, + (maxLen >> 24) & 0xFF, + ]; + + /// RESP_SIGNATURE carrying 64 bytes of [fill]. + List signatureResponse(int fill) => + [ResponseCodes.signature, ...List.filled(64, fill)]; + + Uint8List nonce32() => + Uint8List.fromList(List.generate(32, (i) => i + 1)); + + test('signs a 32-byte nonce in one chunk', () async { + final future = connection.sign(nonce32()); + + // 1) CMD_SIGN_START goes out first. + await transport.settle(); + expect(transport.writes.length, 1); + expect(transport.commandAt(0), CommandCodes.signStart); + expect(transport.writes[0].length, 1); + + transport.emit(signStartResponse(128)); + await transport.settle(); + + // 2) One CMD_SIGN_DATA frame carrying the raw 32 bytes. + expect(transport.writes.length, 2); + expect(transport.commandAt(1), CommandCodes.signData); + expect(transport.writes[1].sublist(1), nonce32()); + + transport.emit([ResponseCodes.ok]); + await transport.settle(); + + // 3) CMD_SIGN_FINISH. + expect(transport.writes.length, 3); + expect(transport.commandAt(2), CommandCodes.signFinish); + expect(transport.writes[2].length, 1); + + transport.emit(signatureResponse(0xAB)); + + final signature = await future; + expect(signature.length, 64); + expect(signature.every((b) => b == 0xAB), isTrue); + }); + + test('a second sign works after the first one finished', () async { + Future runSign() async { + final future = connection.sign(nonce32()); + await transport.settle(); + transport.emit(signStartResponse(128)); + await transport.settle(); + transport.emit([ResponseCodes.ok]); + await transport.settle(); + transport.emit(signatureResponse(0x11)); + return future; + } + + expect((await runSign()).length, 64); + transport.writes.clear(); + expect((await runSign()).length, 64); + }); + + test('a concurrent sign is refused instead of interleaving', () async { + final first = connection.sign(nonce32()); + await transport.settle(); + + expect(() => connection.sign(nonce32()), throwsA(isA())); + + transport.emit(signStartResponse(128)); + await transport.settle(); + transport.emit([ResponseCodes.ok]); + await transport.settle(); + transport.emit(signatureResponse(0x22)); + expect((await first).length, 64); + }); + + test('signing on a disposed connection throws StateError', () { + connection.dispose(); + expect(() => connection.sign(nonce32()), throwsA(isA())); + }); + + test('the signature never reaches the debug log', () async { + // Debug logs are uploadable to the bug-report endpoint, so the 64-byte + // Ed25519 signature must not appear in one — not even via the generic + // per-frame hexdump that runs before the dispatch switch. + final logLines = []; + final originalDebugPrint = debugPrint; + final originalEnabled = DebugLogger.isEnabled; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) logLines.add(message); + }; + DebugLogger.setEnabled(true); + addTearDown(() { + debugPrint = originalDebugPrint; + DebugLogger.setEnabled(originalEnabled); + }); + + final future = connection.sign(nonce32()); + await transport.settle(); + transport.emit(signStartResponse(128)); + await transport.settle(); + transport.emit([ResponseCodes.ok]); + await transport.settle(); + transport.emit(signatureResponse(0xAB)); + expect((await future).length, 64); + + // The harness actually captured something, or the assertions below are + // vacuously true. + expect(logLines, isNotEmpty); + + // No line carries the signature bytes in any quantity. + expect( + logLines.where((line) => line.contains('ab ab ab')), + isEmpty, + reason: 'signature bytes were hex-dumped into the debug log', + ); + expect(logLines.where((line) => line.contains('ab ab')), isEmpty); + + // The frame is still logged — by length, with the payload redacted. + expect( + logLines.where((line) => + line.contains('Frame received (65 bytes)') && + line.contains('SIGNATURE payload redacted')), + isNotEmpty, + ); + + // Ordinary frames are untouched: the SIGN_START response and the bare OK + // still hexdump in full. + expect( + logLines.where((line) => + line.contains('Frame received (6 bytes): 13 00 80 00 00 00')), + isNotEmpty, + reason: 'non-signature frames must keep the existing hexdump', + ); + expect( + logLines.where((line) => line.contains('Frame received (1 bytes): 00')), + isNotEmpty, + ); + }); + + group('write gate', () { + test('a concurrent command cannot steal the chunk ack', () async { + final signFuture = connection.sign(nonce32()); + await transport.settle(); + transport.emit(signStartResponse(128)); + await transport.settle(); + + // The sign is now waiting for its chunk OK. A zone-transfer timer fires. + final floodFuture = + connection.setFloodScope(Uint8List.fromList(List.filled(16, 7))); + await transport.settle(); + + // The gate must have held it back: only SIGN_START and SIGN_DATA are out. + expect(transport.writes.length, 2); + expect(transport.commandAt(0), CommandCodes.signStart); + expect(transport.commandAt(1), CommandCodes.signData); + expect( + transport.writes.any((w) => w[0] == CommandCodes.setFloodScope), + isFalse, + reason: 'setFloodScope escaped the sign gate', + ); + + // The single OK on the wire belongs to the sign. + transport.emit([ResponseCodes.ok]); + await transport.settle(); + expect(transport.commandAt(2), CommandCodes.signFinish); + + transport.emit(signatureResponse(0x33)); + expect((await signFuture).length, 64); + + // Once the sign is done the queued command goes out. + await floodFuture; + expect(transport.commandAt(3), CommandCodes.setFloodScope); + }); + + test('queued commands are released in the order they were issued', + () async { + final signFuture = connection.sign(nonce32()); + await transport.settle(); + transport.emit(signStartResponse(128)); + await transport.settle(); + + final a = connection.setPathHashMode(1); + final b = connection.getBatteryVoltage(); + await transport.settle(); + expect(transport.writes.length, 2); + + transport.emit([ResponseCodes.ok]); + await transport.settle(); + transport.emit(signatureResponse(0x44)); + await signFuture; + await a; + await b; + + expect(transport.commandAt(3), CommandCodes.setPathHashMode); + expect(transport.commandAt(4), CommandCodes.getBatteryVoltage); + }); + + test('the gate is released even when the sign fails', () async { + final signFuture = connection.sign(nonce32()); + await transport.settle(); + transport.emit([ResponseCodes.err, 5]); + + await expectLater(signFuture, throwsA(isA())); + + await connection.getBatteryVoltage(); + expect(transport.commandAt(1), CommandCodes.getBatteryVoltage); + }); + }); + + group('failure paths', () { + test('ERR at SIGN_START reports unsupported and does not hang', () async { + final future = connection.sign(nonce32()); + await transport.settle(); + transport.emit([ResponseCodes.err, 1]); + + await expectLater( + future, + throwsA( + isA().having((e) => e.code, 'code', 'unsupported')), + ); + }); + + test('ERR mid-sign reports err, not unsupported', () async { + final future = connection.sign(nonce32()); + await transport.settle(); + transport.emit(signStartResponse(128)); + await transport.settle(); + transport.emit([ResponseCodes.err, 3]); + + await expectLater( + future, + throwsA(isA().having((e) => e.code, 'code', 'err')), + ); + }); + + test('a 300-byte payload is sent as three chunks of 128/128/44', () async { + final payload = + Uint8List.fromList(List.generate(300, (i) => i & 0xFF)); + final future = connection.sign(payload); + await transport.settle(); + transport.emit(signStartResponse(1024)); + await transport.settle(); + + expect(transport.writes[1].length, 1 + 128); + transport.emit([ResponseCodes.ok]); + await transport.settle(); + + expect(transport.writes[2].length, 1 + 128); + transport.emit([ResponseCodes.ok]); + await transport.settle(); + + expect(transport.writes[3].length, 1 + 44); + transport.emit([ResponseCodes.ok]); + await transport.settle(); + + expect(transport.commandAt(4), CommandCodes.signFinish); + transport.emit(signatureResponse(0x55)); + expect((await future).length, 64); + }); + + test('a payload longer than maxSignDataLen is refused before chunking', + () async { + final payload = Uint8List.fromList(List.filled(64, 1)); + final future = connection.sign(payload); + await transport.settle(); + transport.emit(signStartResponse(32)); + + await expectLater( + future, + throwsA(isA() + .having((e) => e.code, 'code', 'data_too_long')), + ); + expect(transport.writes.length, 1, reason: 'no chunk should be written'); + }); + + test('a short RESP_SIGNATURE fast-fails instead of timing out', () async { + final future = connection.sign(nonce32()); + await transport.settle(); + transport.emit(signStartResponse(128)); + await transport.settle(); + transport.emit([ResponseCodes.ok]); + await transport.settle(); + // 20 bytes instead of 64. + transport.emit([ResponseCodes.signature, ...List.filled(20, 9)]); + + await expectLater( + future, + throwsA(isA() + .having((e) => e.code, 'code', 'malformed_response')), + ); + }); + + test('a short RESP_SIGN_START fast-fails', () async { + final future = connection.sign(nonce32()); + await transport.settle(); + transport.emit([ResponseCodes.signStart, 0x00]); // missing the u32 + + await expectLater( + future, + throwsA(isA() + .having((e) => e.code, 'code', 'malformed_response')), + ); + }); + + test('a sign that timed out leaves no state behind', () async { + final timedOut = + connection.sign(nonce32(), timeout: const Duration(milliseconds: 30)); + await expectLater(timedOut, throwsA(isA())); + + // A late response for the abandoned sign must be ignored, not crash. + transport.emit(signStartResponse(128)); + await transport.settle(); + + transport.writes.clear(); + final second = connection.sign(nonce32()); + await transport.settle(); + transport.emit(signStartResponse(128)); + await transport.settle(); + transport.emit([ResponseCodes.ok]); + await transport.settle(); + transport.emit(signatureResponse(0x66)); + expect((await second).length, 64); + }); + + test('an interleaved battery push does not disturb the sign', () async { + final future = connection.sign(nonce32()); + await transport.settle(); + transport.emit(signStartResponse(128)); + await transport.settle(); + + // Battery voltage response (0x0C) arriving mid-sign. + transport.emit([ResponseCodes.batteryVoltage, 0x10, 0x0F, 0x00, 0x00]); + await transport.settle(); + expect(transport.writes.length, 2, reason: 'sign must not advance'); + + transport.emit([ResponseCodes.ok]); + await transport.settle(); + transport.emit(signatureResponse(0x77)); + expect((await future).length, 64); + }); + + test('disconnect aborts an in-flight sign', () async { + final future = connection.sign(nonce32()); + await transport.settle(); + + // Attach the expectation BEFORE triggering the abort. The teardown paths + // reject the sign synchronously, and a future that errors while nothing + // is listening yet is reported to the zone as an unhandled error — which + // fails the test even though expectLater would match it a microtask + // later. The assertion itself is unchanged. + final aborted = expectLater( + future, + throwsA(isA().having((e) => e.code, 'code', 'aborted')), + ); + + await connection.disconnect(); + await aborted; + }); + + test('deleteWardrivingChannelEarly is not blocked by a pending sign', + () async { + final future = connection.sign(nonce32()); + await transport.settle(); + + final aborted = expectLater( + future, + throwsA(isA().having((e) => e.code, 'code', 'aborted')), + ); + + // Must return promptly, not sit behind the 5s sign timeout. + await connection.deleteWardrivingChannelEarly(); + await aborted; + }); + + test('abortPendingSign releases the gate for teardown-time writes', + () async { + final future = connection.sign(nonce32()); + await transport.settle(); + expect(transport.commandAt(0), CommandCodes.signStart); + + final aborted = expectLater( + future, + throwsA(isA().having((e) => e.code, 'code', 'aborted')), + ); + + // What the provider will call FIRST in its disconnect sequence. + connection.abortPendingSign(); + await aborted; + + // A gated write now goes out immediately instead of waiting out the + // sign's 5s timeout — this is the whole point of the public hook. + await connection.getBatteryVoltage(); + expect(transport.commandAt(1), CommandCodes.getBatteryVoltage); + }); + + test('abortPendingSign is safe when no sign is running', () async { + connection.abortPendingSign(); + await connection.getBatteryVoltage(); + expect(transport.commandAt(0), CommandCodes.getBatteryVoltage); + }); + }); +} diff --git a/test/services/offline_session_service_test.dart b/test/services/offline_session_service_test.dart new file mode 100644 index 00000000..e7e7230d --- /dev/null +++ b/test/services/offline_session_service_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:mesh_mapper/services/offline_session_service.dart'; + +/// Regression tests for the offline-session lifecycle duplicate bug. +/// +/// Repro: during offline wardriving the 60s auto-save (and app-pause) call +/// `updateCurrentSession()`, which on first fire creates and *tracks* a session. +/// The final save (`_saveOfflineSession` in AppStateProvider) must finalize that +/// SAME tracked session — if it instead calls `saveSession()` it creates a second +/// session holding the same pings, surfacing as two identical sessions at the +/// same time under Settings → Offline Sessions. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + List> pings(int n) => + List.generate(n, (i) => {'type': 'TX', 'seq': i}); + + group('OfflineSessionService lifecycle', () { + test( + 'auto-save then final save through updateCurrentSession yields ONE session (the fix)', + () async { + SharedPreferences.setMockInitialValues({}); + final service = OfflineSessionService(); + await service.init(); + + // Auto-save fires (60s timer / app pause) — creates + tracks session A. + await service.updateCurrentSession(pings(3), deviceName: 'Test'); + expect(service.sessionCount, 1); + + // Final save (stop/disconnect) routes through updateCurrentSession with the + // full ping set, then finalizes the tracker. + await service.updateCurrentSession(pings(5), deviceName: 'Test'); + service.finalizeCurrentSession(); + + expect(service.sessionCount, 1, + reason: 'final save must update the tracked session in place'); + expect(service.sessions.first.pingCount, 5, + reason: 'session holds the complete final ping set'); + }); + + test( + 'auto-save then final save through saveSession DUPLICATES (the bug being fixed)', + () async { + SharedPreferences.setMockInitialValues({}); + final service = OfflineSessionService(); + await service.init(); + + // Auto-save creates + tracks session A. + await service.updateCurrentSession(pings(5), deviceName: 'Test'); + // Old buggy final-save path: saveSession() ignores the tracked session. + await service.saveSession(pings(5), deviceName: 'Test'); + + expect(service.sessionCount, 2, + reason: + 'documents the duplicate: saveSession creates a second session ' + 'alongside the tracked auto-save session'); + }); + + test('finalize then save creates a fresh session (clean break preserved)', + () async { + SharedPreferences.setMockInitialValues({}); + final service = OfflineSessionService(); + await service.init(); + + await service.updateCurrentSession(pings(3), deviceName: 'Test'); + service.finalizeCurrentSession(); + // New offline session after a finalize should be a distinct file. + await service.updateCurrentSession(pings(2), deviceName: 'Test'); + + expect(service.sessionCount, 2, + reason: 'finalize breaks tracking so the next save is a new session'); + }); + + test( + 'final save with empty queue still finalizes, so next session does not append to the old one', + () async { + SharedPreferences.setMockInitialValues({}); + final service = OfflineSessionService(); + await service.init(); + + // Offline session 1: auto-save tracks a 3-ping session. + await service.updateCurrentSession(pings(3), deviceName: 'Test'); + + // Final save runs with no new pings — the provider's empty-queue path now + // finalizes the tracker instead of leaving it set. + service.finalizeCurrentSession(); + + // Offline session 2 starts and auto-saves 2 pings. + await service.updateCurrentSession(pings(2), deviceName: 'Test'); + + // sessions are sorted newest-first: .last is session 1, .first is session 2. + expect(service.sessionCount, 2, + reason: + 'second session must be its own file, not appended to the first'); + expect(service.sessions.last.pingCount, 3, + reason: 'first session keeps its original 3 pings (no append)'); + expect(service.sessions.first.pingCount, 2, + reason: 'second session holds only its own 2 pings'); + }); + }); +} diff --git a/test/services/ping_service_activity_anchor_test.dart b/test/services/ping_service_activity_anchor_test.dart new file mode 100644 index 00000000..c882cbef --- /dev/null +++ b/test/services/ping_service_activity_anchor_test.dart @@ -0,0 +1,179 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:mesh_mapper/models/connection_state.dart'; +import 'package:mesh_mapper/models/device_model.dart'; +import 'package:mesh_mapper/services/api_queue_service.dart'; +import 'package:mesh_mapper/services/countdown_timer_service.dart'; +import 'package:mesh_mapper/services/gps_service.dart'; +import 'package:mesh_mapper/services/meshcore/connection.dart'; +import 'package:mesh_mapper/services/ping_service.dart'; +import 'package:mesh_mapper/services/wakelock_service.dart'; + +/// #501: only the TX path updated the shared display anchor, so the map's +/// distance-to-previous-ping readout stayed at the session start in Trace and +/// Passive modes. Discovery and trace sends must mark the display-only +/// activity anchor (and must NOT touch the TX skip anchor). + +class _FakeGps implements GpsService { + Position? position; + final List activityMarks = []; + final List pingMarks = []; + + @override + GpsStatus get status => GpsStatus.locked; + + @override + Position? get lastPosition => position; + + @override + Future getFreshPosition( + {Duration timeout = const Duration(seconds: 3)}) => + Future.value(position); + + @override + bool isAccuracyAcceptableForPing(Position position) => true; + + @override + bool canPingAtPosition(Position position) => true; + + @override + double get configuredMinDistance => 25.0; + + @override + void markPingPosition(Position position) => pingMarks.add(position); + + @override + void markActivityPosition(Position position) => activityMarks.add(position); + + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError('GpsService.${invocation.memberName}'); +} + +class _FakeConnection implements MeshCoreConnection { + @override + ConnectionStep get currentStep => ConnectionStep.connected; + + @override + DeviceModel? get deviceModel => null; + + @override + int? get lastNoiseFloor => null; + + @override + int? get wardrivingChannelIndex => null; + + @override + Uint8List? get wardrivingChannelKey => null; + + @override + int? get wardrivingChannelHash => null; + + @override + Stream<({Uint8List raw, double snr, int rssi})> get controlDataStream => + const Stream.empty(); + + @override + Stream get traceDataStream => const Stream.empty(); + + @override + Future sendDiscoveryRequest() => Future.value(Uint8List(4)); + + @override + Future sendTracePath(Uint8List repeaterIdBytes, + {int hopBytes = 1}) => + Future.value(Uint8List(4)); + + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError('MeshCoreConnection.${invocation.memberName}'); +} + +class _FakeApiQueue implements ApiQueueService { + @override + dynamic noSuchMethod(Invocation invocation) { + if (invocation.memberName.toString().contains('enqueue')) { + return Future.value(); + } + throw UnimplementedError('ApiQueueService.${invocation.memberName}'); + } +} + +class _FakeWakelock implements WakelockService { + @override + bool get isEnabled => false; + + @override + Future enable() async {} + + @override + Future disable() async {} + + @override + Future dispose() async {} +} + +Position _pos(double lat, double lon) => Position( + latitude: lat, + longitude: lon, + timestamp: DateTime.now(), + accuracy: 5.0, + altitude: 0.0, + altitudeAccuracy: 1.0, + heading: 0.0, + headingAccuracy: 1.0, + speed: 0.0, + speedAccuracy: 1.0, + ); + +PingService _buildService(_FakeGps gps, _FakeConnection conn) => PingService( + gpsService: gps, + connection: conn, + apiQueue: _FakeApiQueue(), + wakelockService: _FakeWakelock(), + cooldownTimer: CooldownTimer(), + manualPingCooldownTimer: ManualPingCooldownTimer(), + rxWindowTimer: RxWindowTimer(), + discoveryWindowTimer: DiscoveryWindowTimer(), + deviceId: 'TEST', + ); + +void main() { + test('a successful discovery send marks the display anchor', () { + fakeAsync((async) { + final gps = _FakeGps()..position = _pos(45.0, -75.0); + final conn = _FakeConnection(); + final ping = _buildService(gps, conn); + + ping.enableAutoPing(passiveMode: true); + async.flushMicrotasks(); + + expect(gps.activityMarks, [gps.position], + reason: 'the readout must follow the discovery position'); + expect(gps.pingMarks, isEmpty, + reason: 'a discovery must not move the TX skip anchor'); + ping.dispose(); + }); + }); + + test('a successful trace send marks the display anchor', () { + fakeAsync((async) { + final gps = _FakeGps()..position = _pos(45.0, -75.0); + final conn = _FakeConnection(); + final ping = _buildService(gps, conn); + + ping.enableAutoPing(targetedMode: true, targetRepeaterId: '4e'); + async.flushMicrotasks(); + + expect(gps.activityMarks, [gps.position], + reason: 'the readout must follow the trace position'); + expect(gps.pingMarks, isEmpty, + reason: 'a trace must not move the TX skip anchor'); + ping.dispose(); + }); + }); +} diff --git a/test/services/ping_service_pending_disable_test.dart b/test/services/ping_service_pending_disable_test.dart new file mode 100644 index 00000000..4f8efbad --- /dev/null +++ b/test/services/ping_service_pending_disable_test.dart @@ -0,0 +1,341 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:mesh_mapper/models/connection_state.dart'; +import 'package:mesh_mapper/models/device_model.dart'; +import 'package:mesh_mapper/services/api_queue_service.dart'; +import 'package:mesh_mapper/services/countdown_timer_service.dart'; +import 'package:mesh_mapper/services/gps_service.dart'; +import 'package:mesh_mapper/services/meshcore/connection.dart'; +import 'package:mesh_mapper/services/ping_service.dart'; +import 'package:mesh_mapper/services/wakelock_service.dart'; + +/// #496: a disable queued while a ping is in flight must be executed by +/// whatever ends that ping's lifecycle (skip, failure, or listening window), +/// never left for the 12s timeout backstop (or, on v1.3.0, the next ping). +/// +/// The production capture: the user's stop landed during an auto ping's GPS +/// fetch; the ping was then skipped as too-close, so no RX window was armed +/// and nothing drained the flag. Ping controls stayed locked until the NEXT +/// ping's window drained it 41s later. + +class _FakeGps implements GpsService { + Position? position; + bool tooClose = false; + Completer? freshPositionGate; + + @override + GpsStatus get status => GpsStatus.locked; + + @override + Position? get lastPosition => position; + + @override + Future getFreshPosition( + {Duration timeout = const Duration(seconds: 3)}) => + freshPositionGate?.future ?? Future.value(position); + + @override + bool isAccuracyAcceptableForPing(Position position) => true; + + @override + bool canPingAtPosition(Position position) => !tooClose; + + @override + double get configuredMinDistance => 25.0; + + @override + void markPingPosition(Position position) {} + + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError('GpsService.${invocation.memberName}'); +} + +class _FakeConnection implements MeshCoreConnection { + Completer? discoveryGate; + Completer? traceGate; + bool sendPingThrows = false; + + @override + ConnectionStep get currentStep => ConnectionStep.connected; + + @override + DeviceModel? get deviceModel => null; + + @override + int? get lastNoiseFloor => null; + + @override + int? get wardrivingChannelIndex => null; + + @override + Uint8List? get wardrivingChannelKey => null; + + @override + int? get wardrivingChannelHash => null; + + @override + Stream<({Uint8List raw, double snr, int rssi})> get controlDataStream => + const Stream.empty(); + + @override + Stream get traceDataStream => const Stream.empty(); + + @override + Future sendPing(String message) async { + if (sendPingThrows) throw Exception('BLE write failed'); + } + + @override + Future sendDiscoveryRequest() => + discoveryGate?.future ?? Future.value(Uint8List(4)); + + @override + Future sendTracePath(Uint8List repeaterIdBytes, + {int hopBytes = 1}) => + traceGate?.future ?? Future.value(Uint8List(4)); + + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError('MeshCoreConnection.${invocation.memberName}'); +} + +class _FakeApiQueue implements ApiQueueService { + @override + dynamic noSuchMethod(Invocation invocation) { + if (invocation.memberName.toString().contains('enqueue')) { + return Future.value(); + } + throw UnimplementedError('ApiQueueService.${invocation.memberName}'); + } +} + +class _FakeWakelock implements WakelockService { + @override + bool get isEnabled => false; + + @override + Future enable() async {} + + @override + Future disable() async {} + + @override + Future dispose() async {} +} + +Position _pos(double lat, double lon) => Position( + latitude: lat, + longitude: lon, + timestamp: DateTime.now(), + accuracy: 5.0, + altitude: 0.0, + altitudeAccuracy: 1.0, + heading: 0.0, + headingAccuracy: 1.0, + speed: 0.0, + speedAccuracy: 1.0, + ); + +PingService _buildService(_FakeGps gps, _FakeConnection conn) => PingService( + gpsService: gps, + connection: conn, + apiQueue: _FakeApiQueue(), + wakelockService: _FakeWakelock(), + cooldownTimer: CooldownTimer(), + manualPingCooldownTimer: ManualPingCooldownTimer(), + rxWindowTimer: RxWindowTimer(), + discoveryWindowTimer: DiscoveryWindowTimer(), + deviceId: 'TEST', + ); + +void main() { + test('a disable queued during a skipped auto ping executes immediately', () { + fakeAsync((async) { + final gps = _FakeGps()..position = _pos(45.0, -75.0); + final conn = _FakeConnection(); + final ping = _buildService(gps, conn); + + final gate = Completer(); + gps.freshPositionGate = gate; + + ping.enableAutoPing(); + async.flushMicrotasks(); + expect(ping.pingInProgress, isTrue, + reason: 'initial auto ping should be parked on the GPS fetch'); + + ping.disableAutoPing(); + async.flushMicrotasks(); + expect(ping.pendingDisable, isTrue, + reason: 'disable should queue while the ping is in flight'); + + // The ping resumes and is skipped by the 25m check: no RX window armed. + gps.tooClose = true; + gate.complete(gps.position); + async.flushMicrotasks(); + + expect(ping.pendingDisable, isFalse, + reason: 'a skipped ping must drain the queued disable itself'); + expect(ping.autoPingEnabled, isFalse, + reason: 'auto mode must stop when the skipped ping ends'); + ping.dispose(); + }); + }); + + test('a disable queued during a failed TX send executes immediately', () { + fakeAsync((async) { + final gps = _FakeGps()..position = _pos(45.0, -75.0); + final conn = _FakeConnection()..sendPingThrows = true; + final ping = _buildService(gps, conn) + ..getSessionId = (() => 'OTT-20260829-0001') + ..getNextPingCounter = (() => 1); + + final gate = Completer(); + gps.freshPositionGate = gate; + + ping.enableAutoPing(); + async.flushMicrotasks(); + ping.disableAutoPing(); + async.flushMicrotasks(); + expect(ping.pendingDisable, isTrue); + + gate.complete(gps.position); + async.flushMicrotasks(); + + expect(ping.pendingDisable, isFalse, + reason: 'a ping whose BLE send failed must drain the disable'); + expect(ping.autoPingEnabled, isFalse); + ping.dispose(); + }); + }); + + test('passive mode: discovery window completion executes a queued disable', + () { + fakeAsync((async) { + final gps = _FakeGps()..position = _pos(45.0, -75.0); + final conn = _FakeConnection(); + final discGate = Completer(); + conn.discoveryGate = discGate; + final ping = _buildService(gps, conn); + + ping.enableAutoPing(passiveMode: true); + async.flushMicrotasks(); + expect(ping.pingInProgress, isTrue, + reason: 'discovery send should be parked on the BLE write'); + + ping.disableAutoPing(); + async.flushMicrotasks(); + expect(ping.pendingDisable, isTrue); + + discGate.complete(Uint8List(4)); + async.flushMicrotasks(); + + // The 7s discovery window runs out; well before the 12s backstop. + async.elapse(const Duration(seconds: 8)); + + expect(ping.pendingDisable, isFalse, + reason: 'the discovery window end must drain the queued disable'); + expect(ping.autoPingEnabled, isFalse); + ping.dispose(); + }); + }); + + test('targeted mode: trace window completion executes a queued disable', () { + fakeAsync((async) { + final gps = _FakeGps()..position = _pos(45.0, -75.0); + final conn = _FakeConnection(); + final traceGate = Completer(); + conn.traceGate = traceGate; + final ping = _buildService(gps, conn); + + ping.enableAutoPing(targetedMode: true, targetRepeaterId: '4e'); + async.flushMicrotasks(); + expect(ping.pingInProgress, isTrue, + reason: 'trace send should be parked on the BLE write'); + + ping.disableAutoPing(); + async.flushMicrotasks(); + expect(ping.pendingDisable, isTrue); + + traceGate.complete(Uint8List(4)); + async.flushMicrotasks(); + + // The 5s trace window runs out; well before the 12s backstop. + async.elapse(const Duration(seconds: 6)); + + expect(ping.pendingDisable, isFalse, + reason: 'the trace window end must drain the queued disable'); + expect(ping.autoPingEnabled, isFalse); + ping.dispose(); + }); + }); + + test('a throwing provider callback cannot escape the disable drain', () { + fakeAsync((async) { + final gps = _FakeGps()..position = _pos(45.0, -75.0); + final conn = _FakeConnection(); + final discGate = Completer(); + conn.discoveryGate = discGate; + final ping = _buildService(gps, conn) + ..onPendingDisableComplete = + (() async => throw Exception('provider teardown failed')); + + ping.enableAutoPing(passiveMode: true); + async.flushMicrotasks(); + ping.disableAutoPing(); + async.flushMicrotasks(); + expect(ping.pendingDisable, isTrue); + + discGate.complete(Uint8List(4)); + async.flushMicrotasks(); + + // The window-complete drain runs from a void tracker callback: nothing + // awaits it, so an escaping error would be an unhandled async error. + async.elapse(const Duration(seconds: 8)); + + expect(ping.pendingDisable, isFalse); + expect(ping.autoPingEnabled, isFalse); + ping.dispose(); + }); + }); + + test('a successful TX ping still drains the disable at RX window end', () { + fakeAsync((async) { + final gps = _FakeGps()..position = _pos(45.0, -75.0); + final conn = _FakeConnection(); + final ping = _buildService(gps, conn) + ..getSessionId = (() => 'OTT-20260829-0001') + ..getNextPingCounter = (() => 1); + + final gate = Completer(); + gps.freshPositionGate = gate; + + ping.enableAutoPing(); + async.flushMicrotasks(); + ping.disableAutoPing(); + async.flushMicrotasks(); + expect(ping.pendingDisable, isTrue); + + gate.complete(gps.position); + async.flushMicrotasks(); + + // Ping transmitted; the RX window is live. The disable must wait for it + // so in-flight echoes are still collected... + expect(ping.pingInProgress, isTrue, + reason: 'RX window should be running after a successful send'); + expect(ping.pendingDisable, isTrue, + reason: 'the disable must NOT preempt a live RX window'); + + // ...and execute when the window ends. + async.elapse(const Duration(seconds: 6)); + + expect(ping.pendingDisable, isFalse); + expect(ping.autoPingEnabled, isFalse); + ping.dispose(); + }); + }); +} diff --git a/test/services/portal_account_service_test.dart b/test/services/portal_account_service_test.dart new file mode 100644 index 00000000..8bbaddf4 --- /dev/null +++ b/test/services/portal_account_service_test.dart @@ -0,0 +1,1079 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mesh_mapper/services/portal_account_service.dart'; +import 'package:mesh_mapper/services/portal_token_store.dart'; +import 'package:mesh_mapper/utils/debug_logger_io.dart'; + +void main() { + late InMemoryTokenStore store; + late List requests; + + setUp(() { + store = InMemoryTokenStore(); + requests = []; + }); + + /// A MockClient that records every request and answers from [responder]. + MockClient recordingClient( + http.Response Function(http.Request request) responder) { + return MockClient((request) async { + requests.add(request); + return responder(request); + }); + } + + /// Requests to one route. A successful sign-in runs `token` and then `me`, + /// so a bare request count no longer means "exchanged exactly once". + int callsTo(String action) => + requests.where((r) => r.url.queryParameters['action'] == action).length; + + PortalAccountService buildService(MockClient client) => PortalAccountService( + client: client, + store: store, + // appLinks: null keeps the platform deep-link plugin out of unit tests; + // callbacks are driven through handleAuthCallback() directly. + launcher: (uri) async => true, + ); + + /// Seeds a pending PKCE pair as though beginSignIn() had run. + Future seedPending(String state, {Duration age = Duration.zero}) async { + await store.writePendingPkce(PendingPkce( + verifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', + state: state, + createdAt: DateTime.now().subtract(age), + )); + } + + String tokenBody() => jsonEncode({ + 'ok': true, + 'token': 'f' * 64, + 'user': {'id': 7, 'username': 'sparkgap', 'display_name': 'Spark Gap'}, + }); + + group('authorize URL', () { + test('carries the locked PKCE and redirect parameters', () { + final uri = PortalApi.authorizeUrl( + codeChallenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', + state: 'st4te'); + expect(uri.host, 'portal.meshmapper.net'); + expect(uri.path, '/portal.php'); + expect(uri.queryParameters['app_authorize'], '1'); + expect(uri.queryParameters['code_challenge_method'], 'S256'); + expect(uri.queryParameters['redirect_uri'], 'meshmapper-auth://callback'); + expect(uri.queryParameters['state'], 'st4te'); + // The encoded form the portal byte-compares against. + expect(uri.toString(), + contains('redirect_uri=meshmapper-auth%3A%2F%2Fcallback')); + }); + + test('action() builds the machine-lane route', () { + expect(PortalApi.action('nonce').toString(), + 'https://portal.meshmapper.net/portal_app_api.php?action=nonce'); + }); + }); + + group('beginSignIn', () { + test('persists a pending pair and opens the browser', () async { + Uri? opened; + final service = PortalAccountService( + client: recordingClient((_) => http.Response('{}', 200)), + store: store, + launcher: (uri) async { + opened = uri; + return true; + }, + ); + + expect(await service.beginSignIn(), isTrue); + + final pending = await store.readPendingPkce(); + expect(pending, isNotNull); + expect(pending!.verifier.length, 43); + expect(opened, isNotNull); + expect(opened!.queryParameters['state'], pending.state); + expect(opened!.queryParameters['code_challenge']!.length, 43); + expect(requests, isEmpty, reason: 'sign-in must not hit the API'); + }); + }); + + group('handleAuthCallback', () { + test('exchanges the code and persists the token', () async { + await seedPending('st4te'); + final service = + buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + var changed = 0; + service.onAccountChanged = () => changed++; + + await service.handleAuthCallback( + Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=st4te')); + + expect(callsTo('token'), 1); + final request = requests.first; + expect(request.url.queryParameters['action'], 'token'); + expect(request.headers['Content-Type'], + contains('application/x-www-form-urlencoded')); + expect(request.bodyFields['code'], 'a' * 64); + expect(request.bodyFields['code_verifier'], + 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'); + // The token exchange is unauthenticated. + expect(request.headers.containsKey('Authorization'), isFalse); + + expect(await store.readToken(), 'f' * 64); + expect(await store.readPendingPkce(), isNull); + expect(service.isSignedIn, isTrue); + expect(service.account!.username, 'sparkgap'); + expect(service.account!.displayName, 'Spark Gap'); + // Twice: the identity lands first, the device list right behind it. + expect(changed, 2); + }); + + test('ignores a state mismatch and makes zero HTTP calls', () async { + await seedPending('st4te'); + final service = + buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + + await service.handleAuthCallback( + Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=wrong')); + + expect(requests, isEmpty); + expect(service.isSignedIn, isFalse); + expect(await store.readPendingPkce(), isNotNull, + reason: 'a genuine callback may still arrive'); + }); + + test('ignores a callback with no pending pair', () async { + final service = + buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + + await service.handleAuthCallback( + Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=st4te')); + + expect(requests, isEmpty); + expect(service.isSignedIn, isFalse); + }); + + test('ignores an expired pending pair and reports it', () async { + await seedPending('st4te', age: const Duration(minutes: 11)); + final service = + buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + bool? reported; + String? errorCode; + service.onSignInComplete = (success, code) { + reported = success; + errorCode = code; + }; + + await service.handleAuthCallback( + Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=st4te')); + + expect(requests, isEmpty); + expect(await store.readPendingPkce(), isNull); + // A real attempt was live and just died — the UI must stop spinning. + expect(reported, isFalse); + expect(errorCode, 'expired'); + }); + + test('a fresh sign-in fills in the linked-device list', () async { + // The exchange answers with the identity but no pubkeys, so the account + // card would read "0 device(s)" until the next radio connect ran `me`. + await seedPending('st4te'); + final service = buildService(recordingClient( + (request) => request.url.queryParameters['action'] == 'me' + ? http.Response( + jsonEncode({ + 'ok': true, + 'user': {'id': 7, 'username': 'sparkgap'}, + 'pubkeys': [ + {'pubkey': 'a' * 64}, + {'pubkey': 'b' * 64}, + ], + }), + 200) + : http.Response(tokenBody(), 200))); + + await service.handleAuthCallback( + Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=st4te')); + + expect(requests.map((r) => r.url.queryParameters['action']).toList(), + ['token', 'me']); + expect(service.linkedPubkeys.length, 2); + }); + + test('a device list that fails still leaves the user signed in', () async { + await seedPending('st4te'); + final completions = []; + final service = buildService(recordingClient((request) => + request.url.queryParameters['action'] == 'me' + ? http.Response('boom', 500) + : http.Response(tokenBody(), 200))); + service.onSignInComplete = (success, _) => completions.add(success); + + await service.handleAuthCallback( + Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=st4te')); + + expect(completions, [true], reason: 'the sign-in itself succeeded'); + expect(service.isSignedIn, isTrue); + expect(service.account!.username, 'sparkgap'); + expect(service.linkedPubkeys, isEmpty); + }); + + test('exchanges a given code only once', () async { + await seedPending('st4te'); + final service = + buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + final uri = + Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=st4te'); + + // app_links 6.x delivers the cold-start URI on BOTH getInitialLink() + // and uriLinkStream; a second exchange would burn an already-used code. + await service.handleAuthCallback(uri); + await service.handleAuthCallback(uri); + + expect(callsTo('token'), 1); + }); + + test('a concurrent duplicate delivery still exchanges only once', () async { + // The init() race: the uriLinkStream listener runs unawaited while + // getInitialLink() is still in flight, so both deliveries can be inside + // handleAuthCallback at the same time. The gate parks them both on the + // store read, which is where the guard used to leak. + final gated = _GatedStore(); + await gated.writePendingPkce(PendingPkce( + verifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', + state: 'st4te', + createdAt: DateTime.now(), + )); + final service = PortalAccountService( + client: recordingClient((_) => http.Response(tokenBody(), 200)), + store: gated, + launcher: (uri) async => true, + ); + final uri = + Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=st4te'); + + final first = service.handleAuthCallback(uri); + final second = service.handleAuthCallback(uri); + gated.openGate(); + await Future.wait([first, second]); + + expect(callsTo('token'), 1, + reason: 'the portal burns a code on first use'); + expect(service.isSignedIn, isTrue); + expect(gated.pendingReads, 1, + reason: 'the duplicate must bounce before it reaches the store'); + }); + + test('a state mismatch does not poison the code for the genuine callback', + () async { + await seedPending('st4te'); + final service = + buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + + // A stale or hostile callback carrying this code but the wrong state. + await service.handleAuthCallback( + Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=wrong')); + expect(requests, isEmpty); + + // The genuine callback arrives afterwards with the SAME code and must + // still be exchangeable. + await service.handleAuthCallback( + Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=st4te')); + + expect(callsTo('token'), 1); + expect(service.isSignedIn, isTrue); + }); + + test('access_denied clears the pending pair without an exchange', () async { + await seedPending('st4te'); + final service = + buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + bool? reported; + String? code; + service.onSignInComplete = (success, errorCode) { + reported = success; + code = errorCode; + }; + + await service.handleAuthCallback(Uri.parse( + 'meshmapper-auth://callback?error=access_denied&state=st4te')); + + expect(requests, isEmpty); + expect(await store.readPendingPkce(), isNull); + expect(reported, isFalse); + expect(code, 'access_denied'); + }); + + test('ignores a URI with a foreign scheme', () async { + await seedPending('st4te'); + final service = + buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + + await service + .handleAuthCallback(Uri.parse('meshmapper://custom-api?url=x&key=y')); + await service.handleAuthCallback(Uri.parse( + 'meshmapper-auth://elsewhere?code=${'a' * 64}&state=st4te')); + + expect(requests, isEmpty); + }); + + test('a failed exchange leaves the user signed out', () async { + await seedPending('st4te'); + final service = buildService(recordingClient( + (_) => http.Response('{"error":"code_invalid"}', 400))); + String? errorCode; + service.onSignInComplete = (success, code) => errorCode = code; + + await service.handleAuthCallback( + Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=st4te')); + + expect(service.isSignedIn, isFalse); + expect(await store.readToken(), isNull); + expect(errorCode, 'code_invalid'); + }); + + // A hostile or broken portal answering `"user": []` / `"user": "bob"` used + // to throw a TypeError on the cast. Via the uriLinkStream listener that is + // an unawaited future, so it became an unhandled zone error and the whole + // attempt died silently with the code still claimed. + for (final shape in [[], 'bob', 42]) { + test('a non-object user (${shape.runtimeType}) is rejected, not thrown', + () async { + final localStore = InMemoryTokenStore(); + await localStore.writePendingPkce(PendingPkce( + verifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', + state: 'st4te', + createdAt: DateTime.now(), + )); + final service = PortalAccountService( + client: recordingClient((_) => http.Response( + jsonEncode({'ok': true, 'token': 'f' * 64, 'user': shape}), 200)), + store: localStore, + launcher: (uri) async => true, + ); + bool? reported; + String? errorCode; + service.onSignInComplete = (success, code) { + reported = success; + errorCode = code; + }; + + await service.handleAuthCallback(Uri.parse( + 'meshmapper-auth://callback?code=${'a' * 64}&state=st4te')); + + expect(service.isSignedIn, isFalse); + expect(await localStore.readToken(), isNull); + expect(reported, isFalse); + expect(errorCode, 'bad_response'); + }); + } + + test('a throw inside the exchange cannot escape as a zone error', () async { + final throwing = _ThrowingWriteStore(); + await throwing.writePendingPkce(PendingPkce( + verifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', + state: 'st4te', + createdAt: DateTime.now(), + )); + final service = PortalAccountService( + client: recordingClient((_) => http.Response(tokenBody(), 200)), + store: throwing, + launcher: (uri) async => true, + ); + String? errorCode; + service.onSignInComplete = (success, code) => errorCode = code; + + // Must complete normally rather than propagating the keystore failure. + await service.handleAuthCallback( + Uri.parse('meshmapper-auth://callback?code=${'a' * 64}&state=st4te')); + + expect(errorCode, 'bad_response'); + }); + }); + + // A callback is unauthenticated: ANY app on the device can fire + // meshmapper-auth://callback?error=... An error is therefore only believed + // when it answers a sign-in we actually started. + group('error callbacks are only honoured when they answer a live attempt', + () { + test('an error with no pending pair is ignored', () async { + final service = + buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + var completions = 0; + service.onSignInComplete = (_, __) => completions++; + + await service.handleAuthCallback(Uri.parse( + 'meshmapper-auth://callback?error=access_denied&state=st4te')); + + expect(completions, 0); + expect(requests, isEmpty); + }); + + test('an error with a mismatched state leaves the pair intact', () async { + await seedPending('st4te'); + final service = + buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + var completions = 0; + service.onSignInComplete = (_, __) => completions++; + + await service.handleAuthCallback(Uri.parse( + 'meshmapper-auth://callback?error=access_denied&state=wrong')); + + expect(completions, 0, + reason: 'a foreign app must not fail a live sign-in'); + expect(await store.readPendingPkce(), isNotNull, + reason: 'the genuine callback may still arrive'); + }); + + test('a matching error is reported exactly once', () async { + await seedPending('st4te'); + final service = + buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + final reported = []; + service.onSignInComplete = (success, code) => reported.add(code); + final uri = Uri.parse( + 'meshmapper-auth://callback?error=access_denied&state=st4te'); + + await service.handleAuthCallback(uri); + await service.handleAuthCallback(uri); + + expect(reported, ['access_denied']); + expect(await store.readPendingPkce(), isNull); + expect(requests, isEmpty); + }); + + test('a hostile error value is sanitized before it is reported', () async { + await seedPending('st4te'); + final service = + buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + String? errorCode; + service.onSignInComplete = (success, code) => errorCode = code; + final hostile = Uri.encodeQueryComponent(''); + + await service.handleAuthCallback( + Uri.parse('meshmapper-auth://callback?error=$hostile&state=st4te')); + + expect(errorCode, 'denied'); + }); + + test('a concurrent duplicate error is reported only once', () async { + // Same init() race as the code path: getInitialLink() and the + // uriLinkStream listener can both be inside handleAuthCallback at once. + // Sequential dedupe falls out of the pair being burned, but two + // deliveries parked on the store read would BOTH fire onSignInComplete. + final gated = _GatedStore(); + await gated.writePendingPkce(PendingPkce( + verifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', + state: 'st4te', + createdAt: DateTime.now(), + )); + final service = PortalAccountService( + client: recordingClient((_) => http.Response(tokenBody(), 200)), + store: gated, + launcher: (uri) async => true, + ); + final reported = []; + service.onSignInComplete = (success, code) => reported.add(code); + final uri = Uri.parse( + 'meshmapper-auth://callback?error=access_denied&state=st4te'); + + final first = service.handleAuthCallback(uri); + final second = service.handleAuthCallback(uri); + gated.openGate(); + await Future.wait([first, second]); + + expect(reported, ['access_denied']); + expect(gated.pendingReads, 1, + reason: 'the duplicate must bounce before it reaches the store'); + expect(requests, isEmpty); + }); + + test('a wedged keystore still lets the decline reach the UI', () async { + // handleAuthCallback runs unawaited off the uriLinkStream, so a throwing + // deletePendingPkce would escape as a zone error and leave the UI + // spinning on a sign-in that has already been refused. + final wedged = _ThrowingDeletePendingStore(); + await wedged.writePendingPkce(PendingPkce( + verifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', + state: 'st4te', + createdAt: DateTime.now(), + )); + final service = PortalAccountService( + client: recordingClient((_) => http.Response(tokenBody(), 200)), + store: wedged, + launcher: (uri) async => true, + ); + String? errorCode; + service.onSignInComplete = (success, code) => errorCode = code; + + await service.handleAuthCallback(Uri.parse( + 'meshmapper-auth://callback?error=access_denied&state=st4te')); + + expect(errorCode, 'access_denied'); + }); + }); + + group('logging never leaks a secret', () { + test('token, code, verifier and state appear in no log line', () async { + final logs = []; + final originalPrint = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) logs.add(message); + }; + DebugLogger.setEnabled(true); + addTearDown(() { + debugPrint = originalPrint; + DebugLogger.setEnabled(false); + }); + + await seedPending('st4testate'); + final service = + buildService(recordingClient((_) => http.Response(tokenBody(), 200))); + await service.handleAuthCallback(Uri.parse( + 'meshmapper-auth://callback?code=${'a' * 64}&state=st4testate')); + + final joined = logs.join('\n'); + expect(joined, isNot(contains('f' * 64)), reason: 'token leaked'); + expect(joined, isNot(contains('a' * 64)), reason: 'code leaked'); + expect(joined, + isNot(contains('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')), + reason: 'verifier leaked'); + expect(joined, isNot(contains('st4testate')), reason: 'state leaked'); + expect(joined, contains('[ACCOUNT]'), + reason: 'the flow should log something'); + }); + }); + + group('authenticated calls', () { + Future signedIn(MockClient client) async { + store.token = 'f' * 64; + final service = buildService(client); + await service.init(); + return service; + } + + /// Answers per `?action=`, so one test can seed the cache with `me` and + /// then exercise `link`/`unlink` against a cache that is not empty. + MockClient routedClient(Map byAction) => + recordingClient((request) => + byAction[request.url.queryParameters['action']] ?? + http.Response('{"error":"unexpected_action"}', 500)); + + String meBody(List> pubkeys) => jsonEncode({ + 'ok': true, + 'user': {'id': 7, 'username': 'sparkgap'}, + 'pubkeys': pubkeys, + }); + + test('send both auth headers and a form-encoded body', () async { + final service = await signedIn(recordingClient( + (_) => http.Response('{"nonce":"${'b' * 64}"}', 200))); + + // Mixed case in, UPPER out. + final lowerPubkey = 'abc123${'d' * 58}'; + await service.requestNonce(lowerPubkey); + + final request = requests.single; + expect(request.headers['Authorization'], 'Bearer ${'f' * 64}'); + expect(request.headers['X-MM-App-Token'], 'f' * 64); + expect(request.headers['Content-Type'], + contains('application/x-www-form-urlencoded')); + expect(request.url.queryParameters['action'], 'nonce'); + // pubkey is normalised to UPPER hex. + expect(request.bodyFields['pubkey'], lowerPubkey.toUpperCase()); + // Form, never JSON. + expect(request.body, isNot(startsWith('{'))); + }); + + test('nonce is accepted without an ok flag', () async { + final service = await signedIn(recordingClient((_) => + http.Response('{"nonce":"${'b' * 64}","expires_at":123}', 200))); + expect(await service.requestNonce('A' * 64), 'b' * 64); + }); + + test('a nonce that is not 64 hex is rejected', () async { + final service = await signedIn( + recordingClient((_) => http.Response('{"nonce":"short"}', 200))); + expect(await service.requestNonce('A' * 64), isNull); + }); + + test('link success maps to LinkSuccess and carries the nonce', () async { + final service = await signedIn(recordingClient( + (_) => http.Response('{"ok":true,"pubkey":"${'A' * 64}"}', 200))); + + final result = await service.linkDevice( + pubkey: 'a' * 64, + nonce: 'b' * 64, + signature: 'c' * 128, + label: 'Ikoka Stick', + ); + + expect(result, isA()); + expect((result as LinkSuccess).already, isFalse); + final body = requests.single.bodyFields; + expect(body['pubkey'], 'A' * 64); + expect(body['nonce'], 'b' * 64); + expect(body['signature'], 'c' * 128); + expect(body['label'], 'Ikoka Stick'); + }); + + test('an idempotent relink reports already=true', () async { + final service = await signedIn(recordingClient((_) => http.Response( + '{"ok":true,"already":true,"pubkey":"${'A' * 64}"}', 200))); + final result = await service.linkDevice( + pubkey: 'A' * 64, nonce: 'b' * 64, signature: 'c' * 128); + expect((result as LinkSuccess).already, isTrue); + }); + + test('already_linked maps to LinkAlreadyLinkedOtherAccount', () async { + final service = await signedIn(recordingClient( + (_) => http.Response('{"error":"already_linked"}', 409))); + expect( + await service.linkDevice( + pubkey: 'A' * 64, nonce: 'b' * 64, signature: 'c' * 128), + isA(), + ); + }); + + test('adoption_required carries the device count', () async { + final service = await signedIn(recordingClient((_) => + http.Response('{"error":"adoption_required","devices":4}', 409))); + final result = await service.linkDevice( + pubkey: 'A' * 64, nonce: 'b' * 64, signature: 'c' * 128); + expect(result, isA()); + expect((result as LinkAdoptionRequired).devices, 4); + }); + + test('bad_signature maps to LinkServerError with its code', () async { + final service = await signedIn(recordingClient( + (_) => http.Response('{"error":"bad_signature"}', 400))); + final result = await service.linkDevice( + pubkey: 'A' * 64, nonce: 'b' * 64, signature: 'c' * 128); + expect((result as LinkServerError).code, 'bad_signature'); + expect(result.statusCode, 400); + }); + + test('401 token_invalid signs the user out and deletes the token', + () async { + final service = await signedIn(recordingClient( + (_) => http.Response('{"error":"token_invalid"}', 401))); + var reason = ''; + service.onSignedOut = (value) => reason = value; + + final result = await service.linkDevice( + pubkey: 'A' * 64, nonce: 'b' * 64, signature: 'c' * 128); + + expect(result, isA()); + expect(service.isSignedIn, isFalse); + expect(await store.readToken(), isNull); + expect(reason, 'token_invalid'); + }); + + test('a bare 401 is a network error and keeps the session', () async { + // Captive portals and WAFs emit body-less 401s. Treating those as + // "signed out" would log users out on hotel wifi. + final service = + await signedIn(recordingClient((_) => http.Response('', 401))); + final result = await service.linkDevice( + pubkey: 'A' * 64, nonce: 'b' * 64, signature: 'c' * 128); + + expect(result, isA()); + expect(service.isSignedIn, isTrue); + expect(await store.readToken(), 'f' * 64); + }); + + test('me populates the linked-device list', () async { + final service = await signedIn(recordingClient((_) => http.Response( + jsonEncode({ + 'ok': true, + 'user': {'id': 7, 'username': 'sparkgap'}, + 'pubkeys': [ + { + 'pubkey': ('e' * 64), + 'label': 'Stick', + 'name': 'WX7RAW', + 'points': 12, + } + ], + }), + 200))); + + expect(await service.refreshMe(), isTrue); + expect(service.linkedPubkeys.length, 1); + expect(service.linkedPubkeys.single.pubkey, ('e' * 64).toUpperCase()); + expect(service.linkedPubkeys.single.points, 12); + expect(service.account!.displayName, 'sparkgap'); + }); + + test('me is throttled to once an hour unless forced', () async { + final service = await signedIn(recordingClient((_) => http.Response( + '{"ok":true,"user":{"id":7,"username":"a"},"pubkeys":[]}', 200))); + + expect(await service.refreshMe(), isTrue); + expect(await service.refreshMe(), isFalse); + expect(requests.length, 1); + expect(await service.refreshMe(force: true), isTrue); + expect(requests.length, 2); + }); + + test('a non-object user in me is rejected, not thrown', () async { + // Same hostile shape the token exchange already defends against; me is + // awaited by the provider, so a TypeError here would surface as a + // failed reconnect rather than a skipped refresh. + final service = await signedIn(recordingClient( + (_) => http.Response('{"ok":true,"user":[],"pubkeys":[]}', 200))); + expect(await service.refreshMe(), isTrue); + expect(service.account, isNull); + }); + + test('a successful link lands in the cache exactly once', () async { + final service = await signedIn(routedClient({ + 'link': http.Response( + '{"ok":true,"pubkey":"${'A' * 64}","name":"WX7RAW","points":5}', + 200), + })); + var changed = 0; + service.onAccountChanged = () => changed++; + + expect( + await service.linkDevice( + pubkey: 'a' * 64, + nonce: 'b' * 64, + signature: 'c' * 128, + label: 'Ikoka Stick'), + isA(), + ); + + final entry = service.linkedPubkeys.single; + expect(entry.pubkey, 'A' * 64); + expect(entry.label, 'Ikoka Stick'); + expect(entry.name, 'WX7RAW'); + expect(entry.points, 5); + expect(changed, 1); + + // A relink of the SAME radio must not duplicate the entry, and must not + // re-notify: the UI would rebuild the device list for nothing. + expect( + await service.linkDevice( + pubkey: 'a' * 64, nonce: 'b' * 64, signature: 'c' * 128), + isA(), + ); + expect(service.linkedPubkeys.length, 1); + expect(changed, 1, reason: 'nothing was added the second time'); + }); + + test('a link with no label caches an empty label', () async { + final service = await signedIn(routedClient({ + 'link': http.Response('{"ok":true,"pubkey":"${'A' * 64}"}', 200), + })); + await service.linkDevice( + pubkey: 'A' * 64, nonce: 'b' * 64, signature: 'c' * 128); + expect(service.linkedPubkeys.single.label, ''); + expect(service.linkedPubkeys.single.name, ''); + expect(service.linkedPubkeys.single.points, 0); + }); + + test('unlink removes only the named pubkey and keeps the rest', () async { + final service = await signedIn(routedClient({ + 'me': http.Response( + meBody([ + { + 'pubkey': 'a' * 64, + 'label': 'Stick', + 'name': 'WX7RAW', + 'points': 12 + }, + { + 'pubkey': 'e' * 64, + 'label': 'Nano', + 'name': 'WX7NAN', + 'points': 3 + }, + ]), + 200), + 'unlink': http.Response('{"ok":true}', 200), + })); + + // Seed a NON-EMPTY cache first — unlinking out of an empty list passes + // even when the removal never happens. + expect(await service.refreshMe(), isTrue); + expect(service.linkedPubkeys.length, 2); + + var changed = 0; + service.onAccountChanged = () => changed++; + + expect(await service.unlinkDevice('a' * 64), isTrue); + + expect(service.linkedPubkeys.map((p) => p.pubkey).toList(), + [('e' * 64).toUpperCase()]); + expect(changed, 1); + }); + + test('a refused unlink leaves the cache alone', () async { + final service = await signedIn(routedClient({ + 'me': http.Response( + meBody([ + {'pubkey': 'a' * 64, 'label': '', 'name': '', 'points': 0} + ]), + 200), + 'unlink': http.Response('{"error":"not_found"}', 404), + })); + expect(await service.refreshMe(), isTrue); + var changed = 0; + service.onAccountChanged = () => changed++; + + expect(await service.unlinkDevice('a' * 64), isFalse); + expect(service.linkedPubkeys.length, 1); + expect(changed, 0); + }); + + test('a link keeps the pubkeys restored from the cache', () async { + final service = await signedIn(routedClient({ + 'link': http.Response('{"ok":true,"pubkey":"${'C' * 64}"}', 200), + })); + + // Exactly what the provider does on startup: replay the Hive cache into + // the service before anything can mutate it. + service.hydrateLinkedPubkeys([('a' * 64), ('b' * 64).toUpperCase()]); + expect(service.linkedPubkeys.length, 2); + + expect( + await service.linkDevice( + pubkey: 'c' * 64, nonce: 'd' * 64, signature: 'e' * 128), + isA(), + ); + + // The provider mirrors this list back wholesale, so a dropped A/B here + // is a wiped cache on disk. + expect( + service.linkedPubkeys.map((p) => p.pubkey).toList(), + [('A' * 64), ('B' * 64), ('C' * 64)], + ); + }); + + test('an unlink removes only its own pubkey from a restored cache', + () async { + final service = await signedIn(routedClient({ + 'unlink': http.Response('{"ok":true}', 200), + })); + + service.hydrateLinkedPubkeys([('a' * 64), ('b' * 64)]); + + expect(await service.unlinkDevice('a' * 64), isTrue); + + expect(service.linkedPubkeys.map((p) => p.pubkey).toList(), [('B' * 64)]); + }); + + test('hydrating the cache does not notify', () async { + final service = await signedIn(routedClient(const {})); + var changed = 0; + service.onAccountChanged = () => changed++; + + service.hydrateLinkedPubkeys([('a' * 64)]); + + expect(service.linkedPubkeys.length, 1); + expect(changed, 0, reason: 'a load is not a change'); + expect(requests, isEmpty); + }); + + test('a signed-out link is refused without any HTTP call', () async { + final service = buildService( + recordingClient((_) => http.Response('{"ok":true}', 200))); + await service.init(); + expect(service.isSignedIn, isFalse); + + final result = await service.linkDevice( + pubkey: 'a' * 64, nonce: 'b' * 64, signature: 'c' * 128); + + expect(result, isA()); + expect((result as LinkNetworkError).detail, 'no_token'); + expect(requests, isEmpty, + reason: 'a header-less POST can only ever be refused'); + }); + + test('a signed-out nonce request makes no HTTP call', () async { + final service = buildService(recordingClient( + (_) => http.Response('{"nonce":"${'b' * 64}"}', 200))); + await service.init(); + + expect(await service.requestNonce('A' * 64), isNull); + expect(requests, isEmpty); + }); + + test('logout clears locally even when the revoke POST fails', () async { + final service = + await signedIn(recordingClient((_) => http.Response('boom', 500))); + + await service.logout(); + + expect(service.isSignedIn, isFalse); + expect(service.account, isNull); + expect(await store.readToken(), isNull); + // One attempt plus exactly one retry. + expect(requests.length, 2); + expect(requests.every((r) => r.url.queryParameters['action'] == 'logout'), + isTrue); + }); + + test('logout still sends the token it just deleted', () async { + final service = await signedIn( + recordingClient((_) => http.Response('{"ok":true}', 200))); + await service.logout(); + expect(requests.single.headers['X-MM-App-Token'], 'f' * 64); + }); + + /// The backoff is what is LEFT of the block, so it lands a hair under the + /// granted duration. Anything that ran a real clock is close enough. + void expectBackoff(Duration? actual, Duration granted) { + expect(actual, isNotNull); + expect(actual!, lessThanOrEqualTo(granted)); + expect(actual, greaterThan(granted - const Duration(seconds: 5))); + } + + // Every 429 the portal sends is terminal and carries Retry-After. Its + // buckets slide with a rolling penalty, so a blocked call that gets + // retried re-arms a fresh block instead of letting the old one expire. + http.Response rateLimited({String? retryAfter}) => http.Response( + '{"error":"rate_limited"}', + 429, + headers: {if (retryAfter != null) 'retry-after': retryAfter}, + ); + + test('a 429 on me blocks the next refresh even when it is forced', + () async { + final service = await signedIn( + recordingClient((_) => rateLimited(retryAfter: '600'))); + + expect(await service.refreshMe(), isFalse); + expect(requests.length, 1); + + // force skips the local hourly throttle, never a server backoff. + expect(await service.refreshMe(force: true), isFalse); + expect(requests.length, 1, + reason: 'a blocked route must not be hit again'); + + expectBackoff( + service.rateLimitBackoff('me'), const Duration(seconds: 600)); + }); + + test('a 429 is not a sign-out', () async { + final service = await signedIn( + recordingClient((_) => rateLimited(retryAfter: '600'))); + + await service.refreshMe(); + + expect(service.isSignedIn, isTrue); + expect(await store.readToken(), 'f' * 64); + }); + + test('a 429 with no Retry-After still blocks for the default', () async { + final service = await signedIn(recordingClient((_) => rateLimited())); + + await service.refreshMe(); + + expectBackoff( + service.rateLimitBackoff('me'), PortalApi.defaultRetryAfter); + }); + + test('an unparseable Retry-After falls back to the default', () async { + final service = await signedIn(recordingClient( + (_) => rateLimited(retryAfter: 'Sun, 23 Aug 2026 21:00:00 GMT'))); + + await service.refreshMe(); + + expectBackoff( + service.rateLimitBackoff('me'), PortalApi.defaultRetryAfter); + }); + + test('an absurd Retry-After is clamped', () async { + final service = await signedIn( + recordingClient((_) => rateLimited(retryAfter: '99999999'))); + + await service.refreshMe(); + + expectBackoff(service.rateLimitBackoff('me'), PortalApi.maxRetryAfter); + }); + + test('logout does not retry a 429', () async { + final service = await signedIn( + recordingClient((_) => rateLimited(retryAfter: '300'))); + + await service.logout(); + + expect(requests.length, 1, reason: 'the retry would land in the block'); + expect(service.isSignedIn, isFalse); + }); + + test('a 429 on nonce backs the whole link lane off', () async { + final service = await signedIn( + recordingClient((_) => rateLimited(retryAfter: '120'))); + + expect(await service.requestNonce('A' * 64), isNull); + + expectBackoff(service.linkLaneBackoff, const Duration(seconds: 120)); + }); + + test('a blocked route is not knocked on a second time', () async { + final service = await signedIn( + recordingClient((_) => rateLimited(retryAfter: '120'))); + + expect(await service.requestNonce('A' * 64), isNull); + expect(callsTo('nonce'), 1); + + // The portal's bucket slides and a blocked request re-arms a FRESH + // penalty, so the second attempt must never leave the app. + expect(await service.requestNonce('A' * 64), isNull); + expect(callsTo('nonce'), 1); + expectBackoff(service.linkLaneBackoff, const Duration(seconds: 120)); + }); + + test('a block on one route does not block another', () async { + final service = await signedIn(recordingClient((request) => + request.url.queryParameters['action'] == 'nonce' + ? rateLimited(retryAfter: '120') + : http.Response('{"ok":true}', 200))); + + expect(await service.requestNonce('A' * 64), isNull); + expect(await service.unlinkDevice('A' * 64), isTrue); + expect(callsTo('unlink'), 1); + }); + }); +} + +/// A store whose `readPendingPkce` parks until [openGate] is called, so a test +/// can hold two `handleAuthCallback` calls inside the duplicate guard at once. +class _GatedStore extends InMemoryTokenStore { + final Completer _gate = Completer(); + + /// How many times the callback flow got as far as reading the pending pair. + int pendingReads = 0; + + void openGate() => _gate.complete(); + + @override + Future readPendingPkce() async { + pendingReads++; + await _gate.future; + return super.readPendingPkce(); + } +} + +/// A store whose token write blows up, the way a wedged keystore does. Stands +/// in for any throw raised inside the exchange after validation has passed. +class _ThrowingWriteStore extends InMemoryTokenStore { + @override + Future writeToken(String value) async => + throw StateError('keystore unavailable'); +} + +/// A store that cannot burn the pending pair. `SecureTokenStore` swallows its +/// own delete failures, but the service must not depend on that. +class _ThrowingDeletePendingStore extends InMemoryTokenStore { + @override + Future deletePendingPkce() async => + throw StateError('keystore unavailable'); +} diff --git a/test/services/portal_token_store_test.dart b/test/services/portal_token_store_test.dart new file mode 100644 index 00000000..ada7e864 --- /dev/null +++ b/test/services/portal_token_store_test.dart @@ -0,0 +1,314 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/portal_token_store.dart'; + +/// A keystore stand-in that can be told to fail. +/// +/// `FlutterSecureStorage`'s read/write/delete/deleteAll are ordinary +/// overridable instance methods, so a subclass injected through +/// `SecureTokenStore({storage:})` exercises the real recovery paths with no +/// platform channel and no mocking package. +class _FakeSecureStorage extends FlutterSecureStorage { + _FakeSecureStorage({ + Map? values, + this.throwOnRead = false, + this.throwOnWrite = false, + this.throwOnDelete = false, + this.throwOnDeleteAll = false, + }) : values = values ?? {}; + + final Map values; + final bool throwOnRead; + final bool throwOnWrite; + final bool throwOnDelete; + final bool throwOnDeleteAll; + + bool deleteAllCalled = false; + final List deletedKeys = []; + final List writtenKeys = []; + + /// Shaped like the real failure: Android hands back a restored backup's + /// ciphertext without the Keystore key that wrapped it. + static PlatformException get _badPadding => PlatformException( + code: 'Exception encountered', + message: 'javax.crypto.BadPaddingException: pad block corrupted', + ); + + @override + Future read({ + required String key, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async { + if (throwOnRead) throw _badPadding; + return values[key]; + } + + @override + Future write({ + required String key, + required String? value, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async { + if (throwOnWrite) throw _badPadding; + writtenKeys.add(key); + if (value != null) values[key] = value; + } + + @override + Future delete({ + required String key, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async { + if (throwOnDelete) throw _badPadding; + deletedKeys.add(key); + values.remove(key); + } + + @override + Future deleteAll({ + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async { + deleteAllCalled = true; + if (throwOnDeleteAll) throw _badPadding; + values.clear(); + } +} + +void main() { + group('PendingPkce', () { + test('round-trips through JSON', () { + final created = DateTime.fromMillisecondsSinceEpoch(1750000000000); + final original = PendingPkce( + verifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', + state: 'Zm9vYmFyYmF6cXV4MTIzNA', + createdAt: created, + ); + + final restored = PendingPkce.fromJson(original.toJson()); + + expect(restored, isNotNull); + expect(restored!.verifier, original.verifier); + expect(restored.state, original.state); + expect(restored.createdAt, created); + }); + + test('rejects a body with missing fields', () { + expect(PendingPkce.fromJson(const {'state': 'x'}), isNull); + expect(PendingPkce.fromJson(const {}), isNull); + }); + + test('rejects a body with a non-numeric timestamp', () { + expect( + PendingPkce.fromJson( + const {'verifier': 'v', 'state': 's', 'created_at': 'nope'}), + isNull, + ); + }); + }); + + group('InMemoryTokenStore', () { + late InMemoryTokenStore store; + + setUp(() => store = InMemoryTokenStore()); + + test('token write / read / delete', () async { + expect(await store.readToken(), isNull); + await store.writeToken('a' * 64); + expect(await store.readToken(), 'a' * 64); + await store.deleteToken(); + expect(await store.readToken(), isNull); + }); + + test('pending PKCE write / read / delete', () async { + expect(await store.readPendingPkce(), isNull); + final pending = PendingPkce( + verifier: 'v' * 43, + state: 's' * 22, + createdAt: DateTime.fromMillisecondsSinceEpoch(1750000000000), + ); + await store.writePendingPkce(pending); + expect((await store.readPendingPkce())!.verifier, 'v' * 43); + await store.deletePendingPkce(); + expect(await store.readPendingPkce(), isNull); + }); + }); + + group('key names are the wire contract', () { + test('keys are stable', () { + expect(SecureTokenStore.tokenKey, 'portal_app_token'); + expect(SecureTokenStore.pendingPkceKey, 'portal_pending_pkce'); + expect(SecureTokenStore.androidPreferencesName, 'MeshMapperSecure'); + }); + }); + + group('SecureTokenStore stores under the contract keys', () { + test('the token round-trips under portal_app_token', () async { + final fake = _FakeSecureStorage(); + final store = SecureTokenStore(storage: fake); + + await store.writeToken('t' * 64); + + expect(fake.values[SecureTokenStore.tokenKey], 't' * 64); + expect(await store.readToken(), 't' * 64); + + await store.deleteToken(); + + expect(fake.deletedKeys, contains(SecureTokenStore.tokenKey)); + expect(await store.readToken(), isNull); + }); + + test('the pending pair round-trips as JSON under portal_pending_pkce', + () async { + final fake = _FakeSecureStorage(); + final store = SecureTokenStore(storage: fake); + final created = DateTime.fromMillisecondsSinceEpoch(1750000000000); + + await store.writePendingPkce(PendingPkce( + verifier: 'v' * 43, + state: 's' * 22, + createdAt: created, + )); + + expect( + fake.values[SecureTokenStore.pendingPkceKey], contains('"verifier"')); + + final restored = await store.readPendingPkce(); + + expect(restored, isNotNull); + expect(restored!.verifier, 'v' * 43); + expect(restored.state, 's' * 22); + expect(restored.createdAt, created); + + await store.deletePendingPkce(); + + expect(await store.readPendingPkce(), isNull); + }); + + test('an empty stored blob reads as no pending sign-in', () async { + final store = SecureTokenStore( + storage: + _FakeSecureStorage(values: {SecureTokenStore.pendingPkceKey: ''}), + ); + + expect(await store.readPendingPkce(), isNull); + }); + }); + + group('SecureTokenStore reads never throw', () { + test('a throwing read reports signed-out and nukes the store', () async { + final fake = _FakeSecureStorage(throwOnRead: true); + final store = SecureTokenStore(storage: fake); + + expect(await store.readToken(), isNull); + expect(fake.deleteAllCalled, isTrue); + }); + + test('a throwing pending read also degrades to null', () async { + final fake = _FakeSecureStorage(throwOnRead: true); + final store = SecureTokenStore(storage: fake); + + expect(await store.readPendingPkce(), isNull); + expect(fake.deleteAllCalled, isTrue); + }); + + test('a reset that itself fails still degrades to null, never throws', + () async { + final fake = + _FakeSecureStorage(throwOnRead: true, throwOnDeleteAll: true); + final store = SecureTokenStore(storage: fake); + + expect(await store.readToken(), isNull); + expect(fake.deleteAllCalled, isTrue); + }); + + test('a corrupt pending blob is discarded, not thrown', () async { + final fake = _FakeSecureStorage( + values: {SecureTokenStore.pendingPkceKey: 'garbage{{'}); + final store = SecureTokenStore(storage: fake); + + expect(await store.readPendingPkce(), isNull); + expect(fake.deletedKeys, contains(SecureTokenStore.pendingPkceKey)); + }); + + test('a pending blob that is valid JSON but not an object reads as null', + () async { + // Left in place rather than deleted — it decodes fine, it just isn't a + // pending sign-in. Reading it must still not throw. + final store = SecureTokenStore( + storage: _FakeSecureStorage( + values: {SecureTokenStore.pendingPkceKey: '["not","a","map"]'}, + ), + ); + + expect(await store.readPendingPkce(), isNull); + }); + + test('a pending blob missing fields reads as null', () async { + final store = SecureTokenStore( + storage: _FakeSecureStorage( + values: {SecureTokenStore.pendingPkceKey: '{"state":"s"}'}, + ), + ); + + expect(await store.readPendingPkce(), isNull); + }); + }); + + group('SecureTokenStore writes and deletes swallow failures', () { + test('a throwing token write completes', () async { + final store = + SecureTokenStore(storage: _FakeSecureStorage(throwOnWrite: true)); + + await expectLater(store.writeToken('t' * 64), completes); + }); + + test('a throwing token delete completes', () async { + final store = + SecureTokenStore(storage: _FakeSecureStorage(throwOnDelete: true)); + + await expectLater(store.deleteToken(), completes); + }); + + test('a throwing pending write completes', () async { + final store = + SecureTokenStore(storage: _FakeSecureStorage(throwOnWrite: true)); + + await expectLater( + store.writePendingPkce(PendingPkce( + verifier: 'v' * 43, + state: 's' * 22, + createdAt: DateTime.fromMillisecondsSinceEpoch(1750000000000), + )), + completes, + ); + }); + + test('a throwing pending delete completes', () async { + final store = + SecureTokenStore(storage: _FakeSecureStorage(throwOnDelete: true)); + + await expectLater(store.deletePendingPkce(), completes); + }); + }); +} diff --git a/test/services/transport/stream_frame_codec_test.dart b/test/services/transport/stream_frame_codec_test.dart new file mode 100644 index 00000000..8a4dac82 --- /dev/null +++ b/test/services/transport/stream_frame_codec_test.dart @@ -0,0 +1,279 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/transport/stream_frame_codec.dart'; + +void main() { + late StreamFrameCodec codec; + late List receivedFrames; + + setUp(() { + codec = StreamFrameCodec(); + receivedFrames = []; + codec.frames.listen((frame) => receivedFrames.add(frame)); + }); + + tearDown(() { + codec.dispose(); + }); + + Uint8List makeIncomingFrame(List payload) { + final frame = Uint8List(3 + payload.length); + frame[0] = StreamFrameCodec.incomingMarker; // 0x3E + frame[1] = payload.length & 0xFF; + frame[2] = (payload.length >> 8) & 0xFF; + frame.setRange(3, frame.length, payload); + return frame; + } + + group('encode', () { + test('prepends outgoing marker and length', () { + final payload = Uint8List.fromList([0x01, 0x02, 0x03]); + final encoded = StreamFrameCodec.encode(payload); + + expect(encoded.length, 6); + expect(encoded[0], StreamFrameCodec.outgoingMarker); // 0x3C + expect(encoded[1], 3); // length low byte + expect(encoded[2], 0); // length high byte + expect(encoded.sublist(3), payload); + }); + + test('handles max TX payload (172 bytes)', () { + final payload = Uint8List(172); + final encoded = StreamFrameCodec.encode(payload); + + expect(encoded.length, 175); + expect(encoded[0], StreamFrameCodec.outgoingMarker); + expect(encoded[1], 172); // 0xAC + expect(encoded[2], 0); + }); + + test('encodes length as little-endian uint16', () { + final payload = Uint8List(100); + final encoded = StreamFrameCodec.encode(payload); + + expect(encoded[1], 100); + expect(encoded[2], 0); + }); + + test('handles single-byte payload', () { + final payload = Uint8List.fromList([0xFF]); + final encoded = StreamFrameCodec.encode(payload); + + expect(encoded.length, 4); + expect(encoded[3], 0xFF); + }); + }); + + group('addBytes - single frame', () { + test('decodes a complete frame', () async { + final frame = makeIncomingFrame([0x01, 0x02, 0x03]); + codec.addBytes(frame); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 1); + expect(receivedFrames[0], Uint8List.fromList([0x01, 0x02, 0x03])); + }); + + test('decodes frame with max RX payload (300 bytes)', () async { + final payload = List.generate(300, (i) => i & 0xFF); + final frame = makeIncomingFrame(payload); + codec.addBytes(frame); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 1); + expect(receivedFrames[0].length, 300); + }); + }); + + group('addBytes - multiple frames in one chunk', () { + test('extracts two frames from one chunk', () async { + final frame1 = makeIncomingFrame([0xAA]); + final frame2 = makeIncomingFrame([0xBB, 0xCC]); + final combined = Uint8List.fromList([...frame1, ...frame2]); + + codec.addBytes(combined); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 2); + expect(receivedFrames[0], Uint8List.fromList([0xAA])); + expect(receivedFrames[1], Uint8List.fromList([0xBB, 0xCC])); + }); + + test('extracts three frames from one chunk', () async { + final frame1 = makeIncomingFrame([0x01]); + final frame2 = makeIncomingFrame([0x02]); + final frame3 = makeIncomingFrame([0x03]); + final combined = Uint8List.fromList([...frame1, ...frame2, ...frame3]); + + codec.addBytes(combined); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 3); + }); + }); + + group('addBytes - partial frames across chunks', () { + test('header split across two chunks', () async { + final frame = makeIncomingFrame([0x01, 0x02, 0x03]); + + // Send marker + first length byte + codec.addBytes(Uint8List.fromList(frame.sublist(0, 2))); + await Future.delayed(Duration.zero); + expect(receivedFrames.length, 0); + + // Send rest + codec.addBytes(Uint8List.fromList(frame.sublist(2))); + await Future.delayed(Duration.zero); + expect(receivedFrames.length, 1); + expect(receivedFrames[0], Uint8List.fromList([0x01, 0x02, 0x03])); + }); + + test('one byte at a time', () async { + final frame = makeIncomingFrame([0xAA, 0xBB]); + + for (int i = 0; i < frame.length; i++) { + codec.addBytes(Uint8List.fromList([frame[i]])); + await Future.delayed(Duration.zero); + } + + expect(receivedFrames.length, 1); + expect(receivedFrames[0], Uint8List.fromList([0xAA, 0xBB])); + }); + + test('payload split across two chunks', () async { + final frame = makeIncomingFrame([0x01, 0x02, 0x03, 0x04, 0x05]); + + // Send header + partial payload + codec.addBytes(Uint8List.fromList(frame.sublist(0, 5))); + await Future.delayed(Duration.zero); + expect(receivedFrames.length, 0); + + // Send remaining payload + codec.addBytes(Uint8List.fromList(frame.sublist(5))); + await Future.delayed(Duration.zero); + expect(receivedFrames.length, 1); + expect(receivedFrames[0], + Uint8List.fromList([0x01, 0x02, 0x03, 0x04, 0x05])); + }); + }); + + group('addBytes - junk byte handling', () { + test('discards junk bytes before marker', () async { + final junk = [0x41, 0x42, 0x43]; // "ABC" debug text + final frame = makeIncomingFrame([0x01]); + final combined = Uint8List.fromList([...junk, ...frame]); + + codec.addBytes(combined); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 1); + expect(receivedFrames[0], Uint8List.fromList([0x01])); + }); + + test('discards all bytes when no marker present', () async { + codec.addBytes(Uint8List.fromList([0x41, 0x42, 0x43, 0x44])); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 0); + }); + + test('handles junk between two frames', () async { + final frame1 = makeIncomingFrame([0xAA]); + final junk = [0x44, 0x45, 0x42, 0x55, 0x47]; // "DEBUG" + final frame2 = makeIncomingFrame([0xBB]); + final combined = Uint8List.fromList([...frame1, ...junk, ...frame2]); + + codec.addBytes(combined); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 2); + expect(receivedFrames[0], Uint8List.fromList([0xAA])); + expect(receivedFrames[1], Uint8List.fromList([0xBB])); + }); + }); + + group('addBytes - invalid frames', () { + test('skips zero-length frame', () async { + // Zero-length frame: marker + [0x00, 0x00] + final zeroFrame = Uint8List.fromList([0x3E, 0x00, 0x00]); + final validFrame = makeIncomingFrame([0x01]); + final combined = Uint8List.fromList([...zeroFrame, ...validFrame]); + + codec.addBytes(combined); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 1); + expect(receivedFrames[0], Uint8List.fromList([0x01])); + }); + + test('skips oversized frame (>300 bytes)', () async { + // Frame claiming 301 bytes: marker + [0x2D, 0x01] = 301 + final oversizedHeader = Uint8List.fromList([0x3E, 0x2D, 0x01]); + final validFrame = makeIncomingFrame([0x42]); + final combined = Uint8List.fromList([...oversizedHeader, ...validFrame]); + + codec.addBytes(combined); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 1); + expect(receivedFrames[0], Uint8List.fromList([0x42])); + }); + }); + + group('addBytes - edge cases', () { + test('empty addBytes is a no-op', () async { + codec.addBytes(Uint8List(0)); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 0); + }); + + test('only marker byte, then rest later', () async { + codec.addBytes(Uint8List.fromList([0x3E])); + await Future.delayed(Duration.zero); + expect(receivedFrames.length, 0); + + codec.addBytes(Uint8List.fromList([0x02, 0x00, 0xAA, 0xBB])); + await Future.delayed(Duration.zero); + expect(receivedFrames.length, 1); + expect(receivedFrames[0], Uint8List.fromList([0xAA, 0xBB])); + }); + + test('reset clears buffer state', () async { + // Send partial frame + final frame = makeIncomingFrame([0x01, 0x02, 0x03]); + codec.addBytes(Uint8List.fromList(frame.sublist(0, 2))); + await Future.delayed(Duration.zero); + expect(receivedFrames.length, 0); + + // Reset + codec.reset(); + + // Send a complete new frame + final newFrame = makeIncomingFrame([0xFF]); + codec.addBytes(newFrame); + await Future.delayed(Duration.zero); + expect(receivedFrames.length, 1); + expect(receivedFrames[0], Uint8List.fromList([0xFF])); + }); + }); + + group('encode/decode round-trip', () { + test('encode then decode produces original payload', () async { + final original = Uint8List.fromList([0x16, 0x01, 0x4D, 0x65, 0x73]); + final encoded = StreamFrameCodec.encode(original); + + // Simulate what the device would do: change the marker from 0x3C to 0x3E + // (device echoes back with incoming marker) + final incoming = Uint8List.fromList(encoded); + incoming[0] = StreamFrameCodec.incomingMarker; + + codec.addBytes(incoming); + await Future.delayed(Duration.zero); + + expect(receivedFrames.length, 1); + expect(receivedFrames[0], original); + }); + }); +} diff --git a/test/services/watch/watch_geo_builder_test.dart b/test/services/watch/watch_geo_builder_test.dart new file mode 100644 index 00000000..476ca92f --- /dev/null +++ b/test/services/watch/watch_geo_builder_test.dart @@ -0,0 +1,910 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/models/log_entry.dart'; +import 'package:mesh_mapper/models/ping_data.dart'; +import 'package:mesh_mapper/models/repeater.dart'; +import 'package:mesh_mapper/providers/app_state_provider.dart' + show OverlayPingType; +import 'package:mesh_mapper/services/watch/watch_geo_builder.dart'; +import 'package:mesh_mapper/services/watch/watch_models.dart'; +import 'package:mesh_mapper/utils/ping_colors.dart'; + +TxPing _tx(DateTime at, {List heard = const []}) => TxPing( + latitude: 47.6, + longitude: -122.3, + power: 22, + timestamp: at, + deviceId: 'dev', + heardRepeaters: List.of(heard), + ); + +RxPing _rx(DateTime at) => RxPing( + latitude: 47.61, + longitude: -122.31, + repeaterId: '4e', + timestamp: at, + snr: 5.0, + rssi: -90, + ); + +DiscLogEntry _disc(DateTime at, {required bool discovered}) => DiscLogEntry( + timestamp: at, + latitude: 47.62, + longitude: -122.32, + discoveredNodes: discovered + ? [ + DiscoveredNodeEntry( + repeaterId: '7a', + nodeType: 'REPEATER', + localSnr: 7, + localRssi: -88, + remoteSnr: 5, + ), + ] + : [], + ); + +TraceLogEntry _trace(DateTime at, {required bool success}) => TraceLogEntry( + timestamp: at, + latitude: 47.63, + longitude: -122.33, + targetRepeaterId: '8b', + success: success, + ); + +Repeater _repeater({ + required String id, + required double lat, + required double lon, + String name = 'Rep', + String hexId = '', + int? createdAt, + int? staleTime, +}) => + Repeater( + id: id, + hexId: hexId, + name: name, + lat: lat, + lon: lon, + lastHeard: DateTime.now().millisecondsSinceEpoch ~/ 1000, + enabled: 1, + createdAt: createdAt, + staleTime: staleTime, + ); + +void main() { + setUp(() => PingColors.setColorVisionType(ColorVisionType.none)); + + group('buildPings', () { + test('marker identity survives a new event arriving at the front', () { + // These histories insert newest-first. A positional id renumbered every + // surviving marker whenever one arrived, so SwiftUI rebuilt all sixty + // annotations to show one new dot. + final base = DateTime(2026, 8, 12, 10); + final older = List.generate( + 5, (i) => _disc(base.add(Duration(minutes: i)), discovered: true)); + + List idsFor(List entries) => + WatchGeoBuilder.buildPings( + txPings: const [], + rxPings: const [], + discLogEntries: entries, + traceLogEntries: const [], + ).map((p) => p.id).toList(); + + final before = idsFor(older.reversed.toList()); + final arrival = + _disc(base.add(const Duration(minutes: 9)), discovered: true); + final after = idsFor([arrival, ...older.reversed]); + + expect(after.length, before.length + 1); + expect( + after.toSet().containsAll(before), + isTrue, + reason: 'every pre-existing marker must keep its id', + ); + }); + + test('two events of one kind in the same millisecond stay distinct', () { + final at = DateTime(2026, 8, 12, 10); + final pings = WatchGeoBuilder.buildPings( + txPings: const [], + rxPings: const [], + discLogEntries: [ + _disc(at, discovered: true), + _disc(at, discovered: false) + ], + traceLogEntries: const [], + ); + expect(pings.map((p) => p.id).toSet().length, 2); + }); + + test('merges TX and RX newest-first and caps the list', () { + final base = DateTime(2026, 8, 12, 10); + final tx = List.generate(40, (i) => _tx(base.add(Duration(minutes: i)))); + final rx = List.generate(40, (i) => _rx(base.add(Duration(seconds: i)))); + + final pings = WatchGeoBuilder.buildPings( + txPings: tx, + rxPings: rx, + discLogEntries: const [], + traceLogEntries: const [], + ); + + expect(pings.length, WatchWire.maxPings); + for (var i = 1; i < pings.length; i++) { + expect( + pings[i - 1].at.isAfter(pings[i].at) || + pings[i - 1].at.isAtSameMomentAs(pings[i].at), + isTrue, + reason: 'pings must be newest-first', + ); + } + }); + + test('an unanswered TX is coloured as a failure', () { + final answered = _tx( + DateTime(2026, 8, 12, 10, 1), + heard: const [HeardRepeater(repeaterId: '4e', snr: 6)], + ); + final ignored = _tx(DateTime(2026, 8, 12, 10)); + + final pings = WatchGeoBuilder.buildPings( + txPings: [answered, ignored], + rxPings: const [], + discLogEntries: const [], + traceLogEntries: const [], + ); + + final byTime = {for (final p in pings) p.at: p}; + expect( + byTime[answered.timestamp]!.color, + WatchColor.fromColor(PingColors.txSuccess), + ); + expect( + byTime[ignored.timestamp]!.color, + WatchColor.fromColor(PingColors.txFail), + ); + }); + + test('multi-hop-only TX is RX-coloured unless any echo is direct', () { + final multiHopOnly = _tx( + DateTime(2026, 8, 12, 10, 2), + heard: const [ + HeardRepeater( + repeaterId: '4e', + snr: 6, + pathHops: ['7a', '4e'], + ), + HeardRepeater( + repeaterId: '5f', + snr: 3, + pathHops: ['8b', '5f'], + ), + ], + ); + final includesDirect = _tx( + DateTime(2026, 8, 12, 10, 1), + heard: const [ + HeardRepeater( + repeaterId: '4e', + snr: 6, + pathHops: ['7a', '4e'], + ), + HeardRepeater(repeaterId: '5f', snr: 3), + ], + ); + + final pings = WatchGeoBuilder.buildPings( + txPings: [multiHopOnly, includesDirect], + rxPings: const [], + discLogEntries: const [], + traceLogEntries: const [], + ); + + final byTime = {for (final ping in pings) ping.at: ping}; + expect(byTime[multiHopOnly.timestamp]!.kind, 'tx'); + expect( + byTime[multiHopOnly.timestamp]!.color, + WatchColor.fromColor(PingColors.rx), + ); + expect( + byTime[includesDirect.timestamp]!.color, + WatchColor.fromColor(PingColors.txSuccess), + ); + }); + + test('outcome colour follows the newest event across every history', () { + final base = DateTime(2026, 8, 12, 10); + final tx = _tx( + base, + heard: const [HeardRepeater(repeaterId: '4e', snr: 6)], + ); + final discovery = _disc( + base.add(const Duration(seconds: 1)), + discovered: true, + ); + final trace = _trace( + base.add(const Duration(seconds: 2)), + success: false, + ); + final rx = _rx(base.add(const Duration(seconds: 3))); + + WatchColor? latest({ + List rxPings = const [], + List traces = const [], + }) => + WatchGeoBuilder.latestPingColor( + txPings: [tx], + rxPings: rxPings, + discLogEntries: [discovery], + traceLogEntries: traces, + ); + + expect(latest(), WatchColor.fromColor(PingColors.discSuccess)); + expect( + latest(traces: [trace]), + WatchColor.fromColor(PingColors.noResponse), + ); + expect( + latest(rxPings: [rx], traces: [trace]), + WatchColor.fromColor(PingColors.rx), + ); + }); + + test('latest outcome uses the marker rule for multi-hop-only TX', () { + final multiHop = _tx( + DateTime(2026, 8, 12, 10), + heard: const [ + HeardRepeater( + repeaterId: '4e', + snr: 6, + pathHops: ['7a', '4e'], + ), + ], + ); + + final color = WatchGeoBuilder.latestPingColor( + txPings: [multiHop], + rxPings: const [], + discLogEntries: const [], + traceLogEntries: const [], + ); + + expect(color, WatchColor.fromColor(PingColors.rx)); + }); + + test('discovery markers use response success and failure colours', () { + final answered = _disc( + DateTime(2026, 8, 12, 10, 1), + discovered: true, + ); + final unanswered = _disc( + DateTime(2026, 8, 12, 10), + discovered: false, + ); + + final pings = WatchGeoBuilder.buildPings( + txPings: const [], + rxPings: const [], + discLogEntries: [answered, unanswered], + traceLogEntries: const [], + ); + + final byTime = {for (final ping in pings) ping.at: ping}; + expect(byTime[answered.timestamp]!.kind, 'disc'); + expect( + byTime[answered.timestamp]!.color, + WatchColor.fromColor(PingColors.discSuccess), + ); + expect( + byTime[unanswered.timestamp]!.color, + WatchColor.fromColor(PingColors.discFail), + ); + }); + + test('trace markers use the trace result colours', () { + final answered = _trace( + DateTime(2026, 8, 12, 10, 1), + success: true, + ); + final unanswered = _trace( + DateTime(2026, 8, 12, 10), + success: false, + ); + + final pings = WatchGeoBuilder.buildPings( + txPings: const [], + rxPings: const [], + discLogEntries: const [], + traceLogEntries: [answered, unanswered], + ); + + final byTime = {for (final ping in pings) ping.at: ping}; + expect(byTime[answered.timestamp]!.kind, 'trace'); + expect( + byTime[answered.timestamp]!.color, + WatchColor.fromColor(PingColors.traceSuccess), + ); + expect( + byTime[unanswered.timestamp]!.color, + WatchColor.fromColor(PingColors.noResponse), + ); + }); + + test('the cap keeps the newest markers across mixed sources', () { + final base = DateTime(2026, 8, 12, 10); + final oldTx = List.generate( + 60, + (i) => _tx(base.subtract(Duration(minutes: i + 1))), + ); + final latestTx = _tx(base.add(const Duration(seconds: 1))); + final latestRx = _rx(base.add(const Duration(seconds: 2))); + final latestDisc = _disc( + base.add(const Duration(seconds: 3)), + discovered: true, + ); + final latestTrace = _trace( + base.add(const Duration(seconds: 4)), + success: true, + ); + + final pings = WatchGeoBuilder.buildPings( + txPings: [...oldTx, latestTx], + rxPings: [latestRx], + discLogEntries: [latestDisc], + traceLogEntries: [latestTrace], + cap: 4, + ); + + expect(pings.map((ping) => ping.kind), ['trace', 'disc', 'rx', 'tx']); + expect(pings.map((ping) => ping.at), [ + latestTrace.timestamp, + latestDisc.timestamp, + latestRx.timestamp, + latestTx.timestamp, + ]); + }); + + test('all four marker types survive a realistic mixed history', () { + final base = DateTime(2026, 8, 12, 10); + + final pings = WatchGeoBuilder.buildPings( + txPings: List.generate( + 12, + (i) => _tx(base.add(Duration(minutes: i * 4))), + ), + rxPings: List.generate( + 8, + (i) => _rx(base.add(Duration(minutes: i * 6 + 1))), + ), + discLogEntries: List.generate( + 6, + (i) => _disc( + base.add(Duration(minutes: i * 8 + 2)), + discovered: i.isEven, + ), + ), + traceLogEntries: List.generate( + 4, + (i) => _trace( + base.add(Duration(minutes: i * 12 + 3)), + success: i.isEven, + ), + ), + ); + + expect(pings.map((ping) => ping.kind).toSet(), { + 'tx', + 'rx', + 'disc', + 'trace', + }); + }); + }); + + group('buildRepeaters', () { + test('excludes the (0,0) "location unknown" sentinel', () { + final repeaters = [ + _repeater(id: 'a', lat: 0, lon: 0), + _repeater(id: 'b', lat: 47.6, lon: -122.3), + ]; + + final built = WatchGeoBuilder.buildRepeaters( + repeaters: repeaters, + heardThisCycle: const {}, + ); + + expect(built.map((r) => r.id), ['b']); + }); + + test('orders by distance from the fix and caps the list', () { + final repeaters = [ + _repeater(id: 'far', lat: 48.6, lon: -122.3), + _repeater(id: 'near', lat: 47.601, lon: -122.3), + _repeater(id: 'mid', lat: 47.7, lon: -122.3), + ]; + + final built = WatchGeoBuilder.buildRepeaters( + repeaters: repeaters, + heardThisCycle: const {}, + lat: 47.6, + lon: -122.3, + cap: 2, + ); + + expect(built.map((r) => r.id), ['near', 'mid']); + }); + + test('marks an RX-only repeater heard through its unique hex prefix', () { + final repeaters = [ + _repeater(id: '01', hexId: 'AA11', lat: 47.6, lon: -122.3), + _repeater(id: '02', hexId: 'BB22', lat: 47.6, lon: -122.3), + _repeater(id: '03', hexId: 'CC33', lat: 47.6, lon: -122.3), + ]; + + final built = WatchGeoBuilder.buildRepeaters( + repeaters: repeaters, + // This is the RX slot's path hash; no Top Heard entry is needed for + // the repeater pin to receive its current-cycle ring. + heardThisCycle: {'BB'}, + ); + + expect( + {for (final r in built) r.id: r.heardThisCycle}, + {'01': false, '02': true, '03': false}, + ); + }); + + test('carries the full hex identity separately from the API id', () { + final built = WatchGeoBuilder.buildRepeaters( + repeaters: [ + _repeater(id: '01', hexId: '4E5D82', lat: 47.6, lon: -122.3), + ], + heardThisCycle: const {}, + ); + + expect(built.single.id, '01'); + expect(built.single.hexId, '4E5D82'); + }); + }); + + group('buildHeard — mirrors the map\'s Top Heard overlay', () { + test('keeps the overlay order, with the RX slot trailing', () { + final built = WatchGeoBuilder.buildHeard( + top: const [ + (repeaterId: '4E5D', snr: 8.5, type: OverlayPingType.tx), + (repeaterId: '77A1', snr: 2.25, type: OverlayPingType.disc), + ], + rxSlot: (repeaterId: 'B914', snr: 9.9), + repeaterByHex: const {}, + topAt: DateTime(2026, 8, 12), + rxAt: DateTime(2026, 8, 12, 0, 1), + ); + + // The RX slot trails even though its SNR is highest — it is a distinct + // row on the map, not a competitor for the top three. + expect(built.map((h) => h.id), ['4E5D', '77A1', 'B914']); + }); + + test('the RX row is purple and ping rows carry their own type colour', () { + final built = WatchGeoBuilder.buildHeard( + top: const [ + (repeaterId: 'AA', snr: 1, type: OverlayPingType.tx), + (repeaterId: 'BB', snr: 1, type: OverlayPingType.disc), + ], + rxSlot: (repeaterId: 'CC', snr: 1), + repeaterByHex: const {}, + topAt: DateTime(2026, 8, 12), + rxAt: DateTime(2026, 8, 12), + ); + + expect(built[0].typeColor, WatchColor.fromColor(PingColors.txSuccess)); + expect(built[1].typeColor, WatchColor.fromColor(PingColors.discSuccess)); + expect(built[2].typeColor, WatchColor.fromColor(PingColors.rx)); + }); + + test('an RX slot of a different hash width still gets its name', () { + final capitol = _repeater( + id: '01', + hexId: '4E5D82', + lat: 47.6, + lon: -122.3, + name: 'Capitol Hill'); + final beacon = _repeater( + id: '02', + hexId: '77A1B0', + lat: 47.6, + lon: -122.3, + name: 'Beacon Hill'); + + // Exactly what the provider now hands in: resolved per distinct length. + final repeaterByHex = WatchGeoBuilder.resolveUniqueHexPrefixes( + repeaters: [capitol, beacon], + prefixes: const ['4E5D', '77A1B0'], + ); + + final built = WatchGeoBuilder.buildHeard( + top: const [(repeaterId: '4E5D', snr: 8.5, type: OverlayPingType.tx)], + rxSlot: (repeaterId: '77A1B0', snr: 9.9), + repeaterByHex: repeaterByHex, + topAt: DateTime(2026, 8, 12), + rxAt: DateTime(2026, 8, 12, 0, 1), + lat: 47.6, + lon: -122.3, + ); + + expect(built[0].name, 'Capitol Hill'); + expect(built[1].name, 'Beacon Hill', + reason: 'the RX row lost its name to a single-length index'); + expect(built[1].distanceM, isNotNull, + reason: 'distance is resolved from the same repeater match'); + }); + + test('never exceeds the wire cap of three rows plus the RX slot', () { + final built = WatchGeoBuilder.buildHeard( + top: const [ + (repeaterId: 'A', snr: 4, type: OverlayPingType.tx), + (repeaterId: 'B', snr: 3, type: OverlayPingType.tx), + (repeaterId: 'C', snr: 2, type: OverlayPingType.tx), + (repeaterId: 'D', snr: 1, type: OverlayPingType.tx), + ], + rxSlot: (repeaterId: 'E', snr: 0), + repeaterByHex: const {}, + topAt: DateTime(2026, 8, 12), + rxAt: DateTime(2026, 8, 12), + ); + + expect(built.length, WatchWire.maxHeard); + }); + + test('uppercases the hex id and resolves name plus distance', () { + final built = WatchGeoBuilder.buildHeard( + top: const [(repeaterId: '4e5d', snr: 6, type: OverlayPingType.tx)], + repeaterByHex: { + '4E5D': _repeater( + id: '01', + hexId: '4E5D82', + name: 'Capitol Hill', + lat: 47.61, + lon: -122.3), + }, + topAt: DateTime(2026, 8, 12), + rxAt: null, + lat: 47.6, + lon: -122.3, + ); + + expect(built.single.id, '4E5D'); + expect(built.single.name, 'Capitol Hill'); + expect(built.single.distanceM, closeTo(1112, 50)); + }); + + test('leaves the name null when the hash resolves to nothing', () { + final built = WatchGeoBuilder.buildHeard( + top: const [(repeaterId: 'AB', snr: 1, type: OverlayPingType.tx)], + repeaterByHex: const {}, + topAt: DateTime(2026, 8, 12), + rxAt: null, + ); + + expect(built.single.id, 'AB'); + expect(built.single.name, isNull, + reason: 'a confidently wrong name is worse than none'); + expect(built.single.distanceM, isNull); + }); + }); + + group('resolveRepeater', () { + // The catalogue's hex_id is optional in the API and defaults to empty, and + // an overlay row is labelled with the hex truncated to the zone's hop + // width. Both facts are load-bearing. + final capitol = _repeater( + id: '01', + hexId: '4E5D82AA', + name: 'Capitol Hill', + lat: 47.6, + lon: -122.3, + ); + final noHex = _repeater( + id: '02', + hexId: '', + name: 'Site Without Hex', + lat: 47.5, + lon: -122.3, + ); + + test('a repeater with no hex never makes another lookup ambiguous', () { + // id.startsWith('') is true for every id, so one such entry used to make + // every prefix lookup ambiguous and every name disappear. + final resolved = WatchGeoBuilder.resolveRepeater( + repeaters: [capitol, noHex], + id: '4E5D82AA00112233', + hopBytes: 1, + ); + + expect(resolved?.name, 'Capitol Hill'); + }); + + test('the display hash resolves through the zone hop width', () { + expect( + WatchGeoBuilder.resolveRepeater( + repeaters: [capitol, noHex], + id: '4E', + hopBytes: 1, + )?.name, + 'Capitol Hill', + ); + }); + + test('genuine ambiguity is still refused', () { + final beacon = _repeater( + id: '03', + hexId: '4E99F1BB', + name: 'Beacon Hill', + lat: 47.5, + lon: -122.3, + ); + + expect( + WatchGeoBuilder.resolveRepeater( + repeaters: [capitol, beacon], + id: '4E', + hopBytes: 1, + ), + isNull, + ); + }); + }); + + group('resolveOverlayRepeaters — name from the identity the ping carried', + () { + // Two repeaters sharing a leading byte. With one hop byte the wrist shows + // "4E" for both, and a name resolved from that alone would be a coin flip — + // which is why the prefix rules refuse. A discovery response, though, + // carries the responder's full public key. + final capitol = _repeater( + id: '01', + hexId: '4E5D82AA11', + name: 'Capitol Hill', + lat: 47.6, + lon: -122.3, + ); + final beacon = _repeater( + id: '02', + hexId: '4E99F1BB22', + name: 'Beacon Hill', + lat: 47.5, + lon: -122.3, + ); + + test('a full public key names a row the path hash cannot', () { + expect( + WatchGeoBuilder.resolveUniqueHexPrefixes( + repeaters: [capitol, beacon], + prefixes: const ['4E'], + ), + isEmpty, + reason: 'the hash alone is genuinely ambiguous, and must stay refused', + ); + + final resolved = WatchGeoBuilder.resolveOverlayRepeaters( + repeaters: [capitol, beacon], + displayIds: const ['4E'], + identities: const {'4E': '4E99F1BB2233445566'}, + ); + + expect(resolved['4E']?.name, 'Beacon Hill', + reason: 'the responder published exactly who it was'); + }); + + test('a row with no identity keeps the ordinary prefix rules', () { + // TX echoes and passive RX carry only the path byte. Nothing changes for + // them, including the refusal to guess. + expect( + WatchGeoBuilder.resolveOverlayRepeaters( + repeaters: [capitol, beacon], + displayIds: const ['4E'], + ), + isEmpty, + ); + + final unique = WatchGeoBuilder.resolveOverlayRepeaters( + repeaters: [capitol, beacon], + displayIds: const ['4E5D'], + ); + expect(unique['4E5D']?.name, 'Capitol Hill'); + }); + + test('an identity that matches nothing falls back rather than giving up', + () { + // A catalogue entry with no hex at all still resolves by its short ID + // through the prefix path, so an unmatched identity must not strand it. + final resolved = WatchGeoBuilder.resolveOverlayRepeaters( + repeaters: [capitol, beacon], + displayIds: const ['4E5D'], + identities: const {'4E5D': 'FFFFFFFFFFFF'}, + ); + + expect(resolved['4E5D']?.name, 'Capitol Hill'); + }); + + test('a duplicated catalogue entry is still refused', () { + final duplicate = _repeater( + id: '03', + hexId: '4E99F1BB22', + name: 'Beacon Hill (dup)', + lat: 47.5, + lon: -122.3, + ); + + final resolved = WatchGeoBuilder.resolveOverlayRepeaters( + repeaters: [capitol, beacon, duplicate], + displayIds: const ['4E'], + identities: const {'4E': '4E99F1BB2233445566'}, + ); + + expect(resolved, isEmpty, + reason: 'a longer identity makes collision unlikely, not impossible'); + }); + + test('mixed rows each use the best identity they have', () { + // One discovery row that knows exactly who answered, one TX echo that + // does not. The echo must not borrow the discovery row's certainty. + final resolved = WatchGeoBuilder.resolveOverlayRepeaters( + repeaters: [capitol, beacon], + displayIds: const ['4E', '4E5D'], + identities: const {'4E': '4E99F1BB2233445566'}, + ); + + expect(resolved['4E']?.name, 'Beacon Hill'); + expect(resolved['4E5D']?.name, 'Capitol Hill'); + }); + }); + + group('indexByHexPrefix', () { + test('resolves a link from a path-hash prefix to the full hex', () { + final repeater = + _repeater(id: '01', hexId: '4E5D82', lat: 47.6, lon: -122.3); + + final linked = WatchGeoBuilder.resolveUniqueHexPrefixes( + repeaters: [repeater], + prefixes: const ['4E5D'], + ); + + expect(linked['4E5D'], same(repeater)); + }); + + test('resolves every distinct prefix length in one pass', () { + // The RX slot's path hash can be a wider or narrower hash than the top + // rows', because the two come from different pings and the zone's + // hop-byte count sets the width. Indexing at one length left the odd row + // out with no name and no distance while every other row resolved. + final short = _repeater( + id: '01', + hexId: '4E5D82', + lat: 47.6, + lon: -122.3, + name: 'Capitol Hill'); + final long = _repeater( + id: '02', + hexId: '77A1B0', + lat: 47.6, + lon: -122.3, + name: 'Beacon Hill'); + + final linked = WatchGeoBuilder.resolveUniqueHexPrefixes( + repeaters: [short, long], + prefixes: const ['4E', '77A1B0'], + ); + + expect(linked['4E']?.name, 'Capitol Hill'); + expect(linked['77A1B0']?.name, 'Beacon Hill', + reason: 'a row of a different hash width must still resolve'); + }); + + test('an ambiguous link prefix resolves to nothing', () { + final linked = WatchGeoBuilder.resolveUniqueHexPrefixes( + repeaters: [ + _repeater(id: '01', hexId: '4E5D82', lat: 47.6, lon: -122.3), + _repeater(id: '02', hexId: '4E99F1', lat: 47.7, lon: -122.3), + ], + prefixes: const ['4E'], + ); + + expect(linked, isEmpty, + reason: 'a line to the wrong repeater is worse than no line'); + }); + + test('resolves a prefix owned by exactly one repeater', () { + final index = WatchGeoBuilder.indexByHexPrefix([ + _repeater(id: '01', hexId: '4E5D82', lat: 47.6, lon: -122.3, name: 'A'), + _repeater(id: '02', hexId: '77A1B0', lat: 47.6, lon: -122.3, name: 'B'), + ], 4); + + expect(index['4E5D']?.name, 'A'); + expect(index['77A1']?.name, 'B'); + }); + + test('drops prefixes shared by more than one repeater', () { + // The exact collision a 1-byte path hash produces. + final index = WatchGeoBuilder.indexByHexPrefix([ + _repeater(id: '01', hexId: '4E5D82', lat: 47.6, lon: -122.3, name: 'A'), + _repeater(id: '02', hexId: '4E99F1', lat: 47.6, lon: -122.3, name: 'B'), + ], 2); + + expect(index['4E'], isNull, + reason: 'ambiguous prefixes must resolve to no name at all'); + }); + + test('ignores repeaters whose hex is shorter than the prefix', () { + final index = WatchGeoBuilder.indexByHexPrefix([ + _repeater(id: '01', hexId: '4E', lat: 47.6, lon: -122.3, name: 'A'), + ], 4); + + expect(index, isEmpty); + }); + }); + + group('movedEnough', () { + test('always sends the first fix', () { + expect( + WatchWire.movedEnough( + lastLat: null, + lastLon: null, + lat: 47.6, + lon: -122.3, + ), + isTrue, + ); + }); + + test('suppresses stationary GPS jitter', () { + expect( + WatchWire.movedEnough( + lastLat: 47.6, + lastLon: -122.3, + lat: 47.60002, + lon: -122.30002, + ), + isFalse, + ); + }); + + test('passes once the fix moves past the threshold', () { + expect( + WatchWire.movedEnough( + lastLat: 47.6, + lastLon: -122.3, + lat: 47.6005, + lon: -122.3, + ), + isTrue, + ); + }); + }); + + group('colour vision', () { + test('ping colours follow the active palette', () { + PingColors.setColorVisionType(ColorVisionType.none); + final standard = WatchGeoBuilder.pingColor('tx', true); + + PingColors.setColorVisionType(ColorVisionType.protanopia); + final protan = WatchGeoBuilder.pingColor('tx', true); + + expect(protan, isNot(standard)); + expect(protan, WatchColor.fromColor(PingColors.txSuccess)); + }); + + test('every palette resolves all ping kinds without throwing', () { + for (final type in ColorVisionType.values) { + PingColors.setColorVisionType(type); + for (final kind in ['tx', 'rx', 'disc', 'trace', 'bogus']) { + for (final success in [true, false]) { + expect(WatchGeoBuilder.pingColor(kind, success), isA()); + } + } + } + }); + }); +} diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart new file mode 100644 index 00000000..bc842fe0 --- /dev/null +++ b/test/services/watch/watch_wire_contract_test.dart @@ -0,0 +1,2190 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/external_commands/external_command_models.dart'; +import 'package:mesh_mapper/services/external_commands/external_session_commands.dart'; +import 'package:mesh_mapper/services/live_activity/live_activity_models.dart'; +import 'package:mesh_mapper/services/watch/watch_bridge_service.dart'; +import 'package:mesh_mapper/services/watch/watch_models.dart'; + +/// These tests guard the Dart↔Swift contract. +/// +/// `ios/Shared/MeshMapperWatchPayload.swift` decodes this JSON with a synthesised +/// `Codable`, which matches on exact key names. A rename on either side compiles +/// fine and fails silently at runtime on the wrist — so the key set is asserted +/// here rather than trusted. +WatchSnapshot _snapshot({ + WatchHapticCue? cue, + String phaseTitle = 'Listening', + bool isConnected = true, + int? phaseDurationMs, + WatchGeo? geo, + bool mapGeoIncluded = true, + List availableStartModes = const [WatchStartMode.passive], + DateTime? updatedAt, +}) => + WatchSnapshot( + core: LiveActivitySnapshot( + sessionId: 'session-1', + mode: 'Active', + phase: LiveActivityPhase.listening, + phaseTitle: phaseTitle, + phaseDetail: 'Waiting for echoes', + phaseEndsAt: DateTime.fromMillisecondsSinceEpoch(1760000000000), + isConnected: isConnected, + zoneCode: 'SEA', + txCount: 3, + rxCount: 2, + discoveryCount: 1, + traceCount: 0, + queueSize: 4, + repeaters: const [], + totalHeardCount: 0, + repeatersAreCurrent: true, + updatedAt: DateTime.fromMillisecondsSinceEpoch(1759999999000), + ), + geo: geo ?? + const WatchGeo( + pings: [], + repeaters: [], + heard: [], + linkedRepeaterIds: [], + ), + controls: const WatchControls( + canStartStop: true, + canManualPing: false, + isSessionActive: true, + ), + mapGeoIncluded: mapGeoIncluded, + availableStartModes: availableStartModes, + pingColor: const WatchColor(1, 0, 0), + phaseDurationMs: phaseDurationMs, + cue: cue, + updatedAt: + updatedAt ?? DateTime.fromMillisecondsSinceEpoch(1759999999000), + ); + +void main() { + group('wire contract', () { + test('top-level keys match the Swift WatchSnapshot', () { + final map = _snapshot().toMap(); + + expect( + map.keys.toSet(), + { + 'wireVersion', + 'sessionId', + 'mode', + 'phase', + 'phaseTitle', + 'phaseDetail', + 'phaseEndsAtMs', + 'phaseDurationMs', + 'isConnected', + 'zoneCode', + 'txCount', + 'rxCount', + 'discoveryCount', + 'traceCount', + 'queueSize', + 'pingColor', + 'mapGeoIncluded', + 'availableStartModes', + 'geo', + 'controls', + 'cue', + 'updatedAtMs', + }, + ); + }); + + test('nested keys match the Swift structs', () { + final map = _snapshot().toMap(); + + expect( + (map['geo']! as Map).keys.toSet(), + {'you', 'pings', 'repeaters', 'heard', 'linkedRepeaterIds'}, + ); + expect( + (map['controls']! as Map).keys.toSet(), + { + 'canStartStop', + 'canManualPing', + 'isSessionActive', + 'manualPingApplicable', + 'manualCooldownEndsAtMs', + 'blockedReason', + }, + ); + expect((map['pingColor']! as Map).keys.toSet(), {'r', 'g', 'b'}); + }); + + test('full snapshots retain every geography field', () { + final at = DateTime.fromMillisecondsSinceEpoch(1759999980000); + final geo = WatchGeo( + you: WatchPosition(lat: 47.6, lon: -122.3, fixedAt: at), + pings: [ + WatchPing( + id: 'ping-1', + lat: 47.61, + lon: -122.31, + kind: 'tx', + color: const WatchColor(0, 1, 0), + at: at, + ), + ], + repeaters: const [ + WatchRepeater( + id: 'database-1', + hexId: '4E5D82', + name: 'Capitol Hill', + lat: 47.62, + lon: -122.32, + color: WatchColor(1, 0, 1), + heardThisCycle: true, + ), + ], + heard: [ + WatchHeardNode( + id: '4E5D', + typeColor: const WatchColor(0, 1, 0), + at: at, + ), + ], + linkedRepeaterIds: const ['database-1'], + ); + final map = _snapshot(geo: geo).toMap(); + final encodedGeo = map['geo']! as Map; + + expect(map['mapGeoIncluded'], isTrue); + expect(encodedGeo['you'], isNotNull); + expect(encodedGeo['pings'], hasLength(1)); + expect(encodedGeo['repeaters'], hasLength(1)); + expect(encodedGeo['heard'], hasLength(1)); + expect(encodedGeo['linkedRepeaterIds'], ['database-1']); + }); + + test('suppressed snapshots clear only map detail', () { + final at = DateTime.fromMillisecondsSinceEpoch(1759999980000); + final geo = WatchGeo( + you: WatchPosition(lat: 47.6, lon: -122.3, fixedAt: at), + pings: [ + WatchPing( + id: 'ping-1', + lat: 47.61, + lon: -122.31, + kind: 'tx', + color: const WatchColor(0, 1, 0), + at: at, + ), + ], + repeaters: const [ + WatchRepeater( + id: 'database-1', + hexId: '4E5D82', + name: 'Capitol Hill', + lat: 47.62, + lon: -122.32, + color: WatchColor(1, 0, 1), + heardThisCycle: true, + ), + ], + heard: [ + WatchHeardNode( + id: '4E5D', + typeColor: const WatchColor(0, 1, 0), + at: at, + ), + ], + linkedRepeaterIds: const ['database-1'], + ); + final map = _snapshot(geo: geo, mapGeoIncluded: false).toMap(); + final encodedGeo = map['geo']! as Map; + + expect(map['mapGeoIncluded'], isFalse); + expect(encodedGeo['you'], isNotNull, + reason: 'the fix is cheap and remains useful state'); + expect(encodedGeo['heard'], hasLength(1), + reason: 'the readout consumes Top Heard'); + expect(encodedGeo['pings'], isEmpty); + expect(encodedGeo['repeaters'], isEmpty); + expect(encodedGeo['linkedRepeaterIds'], isEmpty); + }); + + test('old payload semantics default map geography to included', () { + expect(_snapshot().mapGeoIncluded, isTrue); + expect(_snapshot().toMap()['mapGeoIncluded'], isTrue); + }); + + test('timestamps are epoch milliseconds as doubles', () { + final map = _snapshot().toMap(); + expect(map['updatedAtMs'], isA()); + expect(map['phaseEndsAtMs'], 1760000000000.0); + }); + + test('heard time survives a rebuild without changing the fingerprint', () { + const heardAtMs = 1759999980000.0; + WatchSnapshot withBuildTime(int updatedAtMs) => WatchSnapshot( + core: _snapshot().core, + geo: WatchGeo( + pings: const [], + repeaters: const [], + heard: [ + WatchHeardNode( + id: '4E5D', + name: 'Capitol Hill', + snr: 8.5, + at: DateTime.fromMillisecondsSinceEpoch(1759999980000), + typeColor: const WatchColor(0, 1, 0), + ), + ], + linkedRepeaterIds: const [], + ), + controls: _snapshot().controls, + updatedAt: DateTime.fromMillisecondsSinceEpoch(updatedAtMs), + ); + + String fingerprint(WatchSnapshot snapshot) { + final payload = Map.from(snapshot.toMap()) + ..remove('updatedAtMs'); + return jsonEncode(payload); + } + + final first = withBuildTime(1759999999000); + final rebuilt = withBuildTime(1760000000000); + final heard = (first.toMap()['geo'] as Map)['heard'] as List; + expect((heard.first as Map)['atMs'], heardAtMs); + expect(fingerprint(rebuilt), fingerprint(first)); + }); + + test('repeater wire object carries both database and hex identities', () { + const repeater = WatchRepeater( + id: '01', + hexId: '4E5D82', + name: 'Capitol Hill', + lat: 47.6, + lon: -122.3, + color: WatchColor(1, 0, 0), + heardThisCycle: true, + ); + + expect(repeater.toMap().keys.toSet(), { + 'id', + 'hexId', + 'name', + 'lat', + 'lon', + 'color', + 'heardThisCycle', + }); + }); + + test('geo wire objects pin the key names the watch decodes', () { + // Every one of these is non-optional on the Swift side, so a rename here + // does not degrade — it fails `WatchSnapshot`'s throwing decode and the + // watch stops receiving anything at all. Renaming `fixedAtMs` otherwise + // passes this entire suite. + final position = WatchPosition( + lat: 47.6, + lon: -122.3, + headingDeg: 90, + accuracyM: 5, + fixedAt: DateTime.utc(2026, 8, 12), + ); + expect(position.toMap().keys.toSet(), { + 'lat', + 'lon', + 'headingDeg', + 'accuracyM', + 'fixedAtMs', + }); + + final ping = WatchPing( + id: 'tx-1', + lat: 47.6, + lon: -122.3, + kind: 'tx', + color: const WatchColor(1, 0, 0), + at: DateTime.utc(2026, 8, 12), + ); + expect(ping.toMap().keys.toSet(), { + 'id', + 'lat', + 'lon', + 'kind', + 'color', + 'atMs', + }); + + final heard = WatchHeardNode( + id: '4E5D', + name: 'Capitol Hill', + snr: 7.5, + at: DateTime.utc(2026, 8, 12), + distanceM: 1200, + snrColor: const WatchColor(0, 1, 0), + typeColor: const WatchColor(0, 0, 1), + ); + expect(heard.toMap().keys.toSet(), { + 'id', + 'name', + 'snr', + 'atMs', + 'distanceM', + 'snrColor', + 'typeColor', + }); + }); + + test('failure cues carry their creation time for restart filtering', () { + final issuedAt = DateTime.utc(2026, 8, 12, 10); + final cue = WatchHapticCue( + id: 'failure-1', + kind: 'failure', + issuedAt: issuedAt, + message: 'Could not start', + ); + + expect(cue.toMap(), { + 'id': 'failure-1', + 'kind': 'failure', + 'issuedAtMs': issuedAt.millisecondsSinceEpoch.toDouble(), + 'message': 'Could not start', + }); + }); + + test('a cue stays presentable until the watch would drop it', () { + // The bound the phone attaches by and the bound the watch presents by + // are one rule. `WatchSessionClient.staleAfter` greys the surface and + // drops the cue at 90 s; a phone that stopped attaching earlier would + // recreate the silent-failure bug, because the wearer's wrist is down + // for most of that window. + final issuedAt = DateTime.utc(2026, 8, 12, 10); + final cue = WatchHapticCue( + id: 'failure-1', + kind: 'failure', + issuedAt: issuedAt, + message: 'Could not start', + ); + + expect(cue.isPresentableAt(issuedAt), isTrue); + expect( + cue.isPresentableAt(issuedAt.add(const Duration(seconds: 89))), + isTrue, + reason: 'a wearer who raises their wrist late still gets the reason', + ); + expect( + cue.isPresentableAt(issuedAt.add(WatchWire.cueReadableFor)), + isFalse, + reason: 'past the stale boundary the watch drops it anyway', + ); + }); + + test('the cue survives the snapshot that carried it', () { + // The defect this pins: the phone treated native accepting one payload + // as the wearer having seen the cue, cleared it, and — because the cue + // ID is in the urgency key — immediately sent an urgent, cue-less + // payload that overwrote the retained application context. A suspended + // watch woke to idle UI and no account of the failure. + // + // Both snapshots must carry the cue, and both must agree it is urgent, + // or the cheap preflight key and the built payload drift. + final issuedAt = DateTime.utc(2026, 8, 12, 10); + final cue = WatchHapticCue( + id: 'failure-1', + kind: 'failure', + issuedAt: issuedAt, + message: 'Could not start', + ); + + final first = _snapshot(cue: cue); + final second = _snapshot( + cue: cue, + updatedAt: issuedAt.add(const Duration(seconds: 30)), + ); + + expect(second.toMap()['cue'], first.toMap()['cue']); + expect(second.urgencyKey, first.urgencyKey, + reason: 'a re-attached cue is not a new event to flush for'); + }); + + test('phase duration rides along so the watch can draw its own bar', () { + // Deadline plus duration is everything needed to compute the remaining + // fraction locally, which is why the bar needs no per-second updates. + expect( + _snapshot(phaseDurationMs: 45000).toMap()['phaseDurationMs'], 45000); + expect(_snapshot().toMap()['phaseDurationMs'], isNull); + }); + + test('snapshot carries phone-resolved start modes', () { + expect( + _snapshot( + availableStartModes: const [ + WatchStartMode.passive, + WatchStartMode.hybrid, + ], + ).toMap()['availableStartModes'], + ['passive', 'hybrid'], + ); + }); + + test('old controls default manual ping slot ownership to false', () { + const controls = WatchControls( + canStartStop: true, + canManualPing: true, + isSessionActive: true, + ); + + expect(controls.manualPingApplicable, isFalse); + expect(controls.toMap()['manualPingApplicable'], isFalse); + }); + + test('controls serialize stable manual ping slot ownership', () { + const controls = WatchControls( + canStartStop: true, + canManualPing: false, + isSessionActive: true, + manualPingApplicable: true, + ); + + expect(controls.toMap()['manualPingApplicable'], isTrue); + }); + + test('wire version is stamped so the watch can refuse unknown payloads', + () { + expect(_snapshot().toMap()['wireVersion'], WatchWire.version); + }); + + test('urgency key ignores geo but tracks phase, controls, and cues', () { + final base = _snapshot(); + expect(_snapshot().urgencyKey, base.urgencyKey); + + expect( + _snapshot(phaseTitle: 'Sending').urgencyKey, + isNot(base.urgencyKey), + ); + expect( + _snapshot(isConnected: false).urgencyKey, + isNot(base.urgencyKey), + ); + expect( + _snapshot( + cue: WatchHapticCue( + id: 'c1', + kind: 'success', + issuedAt: DateTime.utc(2026, 8, 12), + ), + ).urgencyKey, + isNot(base.urgencyKey), + ); + }); + }); + + group('command kinds', () { + SessionStartAvailability startAvailability({ + bool isTransmitMode = true, + bool isConnected = true, + bool antennaConfigured = true, + bool powerConfigured = true, + bool isPendingDisable = false, + bool isTargetedRunning = false, + bool isAutoStarting = false, + bool cooldownActive = false, + bool isPingSending = false, + bool rxWindowActive = false, + bool txBlockedByOffline = false, + bool txNotAllowed = false, + bool floodTrafficEnabled = true, + ExternalCommandReason? transmitValidationReason, + }) => + resolveSessionStartAvailability( + isTransmitMode: isTransmitMode, + isConnected: isConnected, + antennaConfigured: antennaConfigured, + powerConfigured: powerConfigured, + isPendingDisable: isPendingDisable, + isTargetedRunning: isTargetedRunning, + isAutoStarting: isAutoStarting, + cooldownActive: cooldownActive, + isPingSending: isPingSending, + rxWindowActive: rxWindowActive, + txBlockedByOffline: txBlockedByOffline, + txNotAllowed: txNotAllowed, + floodTrafficEnabled: floodTrafficEnabled, + transmitValidationReason: transmitValidationReason, + ); + + test('round-trip through the wire names Swift sends', () { + for (final kind in WatchCommandKind.values) { + expect(WatchCommandKind.fromWire(kind.name), kind); + } + }); + + test('an unknown command is rejected rather than guessed at', () { + expect(WatchCommandKind.fromWire('selfDestruct'), isNull); + }); + + test('only the always-present watch projects shared Starting to idle', () { + const sharedLiveActivityPhase = LiveActivityPhase.starting; + + expect( + resolveWatchSurfacePhase( + sharedPhase: sharedLiveActivityPhase, + isSessionActive: false, + isSessionStarting: false, + ), + LiveActivityPhase.idle, + ); + expect(sharedLiveActivityPhase, LiveActivityPhase.starting, + reason: 'the Live Activity consumes the shared resolver directly'); + expect( + resolveWatchSurfacePhase( + sharedPhase: sharedLiveActivityPhase, + isSessionActive: false, + isSessionStarting: true, + ), + LiveActivityPhase.starting, + ); + }); + + test('Hybrid is refused when current zone policy forbids TX', () { + final result = resolveWatchRequestedStartMode( + requestedMode: 'hybrid', + isConnected: true, + txAllowed: false, + ); + + expect(result.mode, isNull); + expect(result.refusal, 'Passive Only'); + }); + + test('an omitted start mode preserves the established phone fallback', () { + final result = resolveWatchRequestedStartMode( + requestedMode: null, + isConnected: true, + txAllowed: false, + ); + + expect(result, (mode: null, refusal: null)); + }); + + test('an old generic Start cannot inherit unsupported Trace mode', () { + expect( + resolveLegacyWatchStartMode( + currentMode: 'targeted', + isConnected: true, + txAllowed: true, + ), + 'passive', + ); + expect( + resolveLegacyWatchStartMode( + currentMode: 'hybrid', + isConnected: true, + txAllowed: true, + ), + 'hybrid', + ); + expect( + resolveLegacyWatchStartMode( + currentMode: 'active', + isConnected: true, + txAllowed: false, + ), + 'passive', + ); + }); + + test('diagnostics stay reachable exactly when they are needed', () { + bool show({ + bool isSupportedPlatform = true, + bool supported = true, + bool paired = false, + bool activated = true, + bool hasEverPaired = false, + }) => + resolveShouldShowWatchDiagnostics( + isSupportedPlatform: isSupportedPlatform, + supported: supported, + paired: paired, + activated: activated, + hasEverPaired: hasEverPaired, + ); + + // The case the old rule got backwards: activation failed, so `paired` + // reads false for a phone that may well have a watch — and the screen + // that would explain the failure was the thing being hidden. + expect(show(activated: false), isTrue, + reason: 'a session that never came up cannot deny a watch exists'); + + // A healthy session that genuinely sees no watch. The common iPhone. + expect(show(), isFalse); + + // Seen now, or seen once. Pairing history stays one-way on purpose: an + // unpaired watch is when this is most useful. + expect(show(paired: true), isTrue); + expect(show(hasEverPaired: true), isTrue); + + // Not an iOS host at all, and a host without WatchConnectivity. + expect(show(isSupportedPlatform: false, activated: false), isFalse); + expect(show(supported: false, activated: false), isFalse, + reason: 'no WatchConnectivity means no watch to diagnose'); + }); + + test('the wrist is never offered a mode every start would refuse', () { + // Hybrid used to be advertised whenever the phone was connected and TX + // was allowed, ignoring Offline Mode — where sessionStartAvailability + // refuses every start with 'Offline Mode'. That is a permanently dead + // button on a screen with room for two. + expect( + resolveAvailableWatchStartModes( + isConnected: true, + txAllowed: true, + offlineMode: true, + floodTrafficEnabled: true, + ), + [WatchStartMode.passive], + ); + + expect( + resolveAvailableWatchStartModes( + isConnected: true, + txAllowed: true, + offlineMode: false, + floodTrafficEnabled: true, + ), + [WatchStartMode.passive, WatchStartMode.hybrid], + ); + + // Passive is always offered: it is what the wrist falls back to, and the + // phone still re-validates the start. + for (final connected in [true, false]) { + for (final tx in [true, false]) { + expect( + resolveAvailableWatchStartModes( + isConnected: connected, + txAllowed: tx, + offlineMode: false, + floodTrafficEnabled: true, + ), + contains(WatchStartMode.passive), + ); + } + } + }); + + test('flood traffic off withdraws Hybrid from the wrist', () { + // The phone builds Send Ping and the Active/Hybrid button inside + // `if (!txNotAllowed && floodTrafficVisible)`, so with flood off neither + // control exists. The preference defaults off and a regional + // `flood_disabled` veto forces it off, so a wrist that ignored this was + // advertising a phone-withheld control in the *default* configuration — + // and in the veto case one a zone admin forbade. + expect( + resolveAvailableWatchStartModes( + isConnected: true, + txAllowed: true, + offlineMode: false, + floodTrafficEnabled: false, + ), + [WatchStartMode.passive], + reason: 'Passive is not flood traffic and stays offered', + ); + }); + + test('an advertised Hybrid is one the start gate actually admits', () { + // The pairing the two resolvers must agree on: whenever Hybrid is + // offered, a start in that mode is not refused for a configuration + // reason. Transient timing guards are deliberately not checked here. + for (final offline in [true, false]) { + for (final flood in [true, false]) { + final offered = resolveAvailableWatchStartModes( + isConnected: true, + txAllowed: true, + offlineMode: offline, + floodTrafficEnabled: flood, + ).contains(WatchStartMode.hybrid); + + final admitted = startAvailability( + isTransmitMode: true, + txBlockedByOffline: offline, + floodTrafficEnabled: flood, + ); + + expect(offered, admitted.allowed, + reason: 'offered and admitted must not drift on ' + 'offline=$offline flood=$flood'); + } + } + }); + + test('flood traffic gates transmit starts but never Passive', () { + final hybrid = + startAvailability(isTransmitMode: true, floodTrafficEnabled: false); + expect(hybrid.allowed, isFalse); + expect(hybrid.reason?.compactText, 'Flood Traffic Off'); + + // Passive monitoring is not flood traffic. The phone's Passive button + // sits outside the flood gate, so the wrist's must too — withdrawing it + // would strand a wearer with no way to start anything at all. + expect( + startAvailability(isTransmitMode: false, floodTrafficEnabled: false) + .allowed, + isTrue, + ); + }); + + test('start admission names every shared blocked precondition', () { + final blocked = { + 'Not connected': startAvailability(isConnected: false), + 'Still stopping': startAvailability(isPendingDisable: true), + 'Trace session active': startAvailability(isTargetedRunning: true), + 'Already starting': startAvailability(isAutoStarting: true), + 'Select antenna option': startAvailability(antennaConfigured: false), + 'Select power level': startAvailability(powerConfigured: false), + 'Offline Mode': startAvailability(txBlockedByOffline: true), + 'Passive Only': startAvailability(txNotAllowed: true), + 'Cooling down': startAvailability(cooldownActive: true), + 'Ping in progress': startAvailability(isPingSending: true), + 'Listening for ping response': startAvailability(rxWindowActive: true), + 'Waiting for GPS lock': startAvailability( + transmitValidationReason: ExternalCommandReason.waitingForGpsLock, + ), + }; + + for (final entry in blocked.entries) { + expect(entry.value.allowed, isFalse, reason: entry.key); + expect(entry.value.reason?.compactText, entry.key); + } + }); + + test('Passive ignores the policy blockers that only constrain a TX start', + () { + final passive = startAvailability( + isTransmitMode: false, + txBlockedByOffline: true, + txNotAllowed: true, + transmitValidationReason: ExternalCommandReason.waitingForGpsLock, + ); + + expect(passive, (allowed: true, reason: null)); + }); + + test('Passive still waits out a manual ping, because Passive transmits', + () { + // Passive Mode starts by sending a discovery request and repeats it every + // 30 s. Letting the wrist start one inside a manual ping's TX window puts + // a second transmit on air where the phone's own Passive button is dead. + final blocked = { + 'Cooling down': + startAvailability(isTransmitMode: false, cooldownActive: true), + 'Ping in progress': + startAvailability(isTransmitMode: false, isPingSending: true), + 'Listening for ping response': + startAvailability(isTransmitMode: false, rxWindowActive: true), + }; + + for (final entry in blocked.entries) { + expect(entry.value.allowed, isFalse, reason: entry.key); + expect(entry.value.reason?.compactText, entry.key); + } + }); + }); + + group('bridge command handling', () { + late WatchBridgeService bridge; + late MethodChannel channel; + late List handled; + late bool nativeAvailable; + late bool syncSucceeds; + late int syncCalls; + late int clearCalls; + + /// Short enough that a suite does not spend its life asleep, long enough + /// that a loaded machine cannot cross it between two adjacent statements. + /// The behaviour under test is the ordering the throttle imposes, never the + /// length of the wire's own 2 s interval. + const throttle = Duration(milliseconds: 80); + + /// Comfortably past [throttle], including the retry it schedules. + const settle = Duration(milliseconds: 200); + + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + channel = const MethodChannel('meshmapper/watch_test'); + bridge = WatchBridgeService( + channel: channel, + minimumNonUrgentInterval: throttle, + mapGeoClaimFreshFor: const Duration(milliseconds: 250), + ); + handled = []; + nativeAvailable = true; + syncSucceeds = true; + syncCalls = 0; + clearCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'status') { + return { + 'supported': true, + 'activated': nativeAvailable, + 'paired': nativeAvailable, + 'installed': nativeAvailable, + 'reachable': nativeAvailable, + }; + } + if (call.method == 'sync') { + syncCalls++; + return syncSucceeds; + } + if (call.method == 'clear') { + clearCalls++; + return null; + } + return null; + }); + }); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + bridge.dispose(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + Future?> sendCommand( + String id, + String kind, { + String? mode, + bool? mapGeoNeeded, + double? issuedAtMs, + double? clockOffsetMs, + bool? forceRefresh, + String? sessionId, + }) async { + final result = await TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall( + MethodCall('command', { + 'id': id, + 'kind': kind, + if (mode != null) 'mode': mode, + if (mapGeoNeeded != null) 'mapGeoNeeded': mapGeoNeeded, + if (issuedAtMs != null) 'issuedAtMs': issuedAtMs, + if (clockOffsetMs != null) 'clockOffsetMs': clockOffsetMs, + if (forceRefresh != null) 'forceRefresh': forceRefresh, + if (sessionId != null) 'sessionId': sessionId, + }), + ), + null, + ); + if (result == null) return null; + return channel.codec.decodeEnvelope(result) as Map?; + } + + test('availability can open after attach without an app restart', () async { + nativeAvailable = false; + final changes = []; + bridge.attachCommandHandler( + (_) => null, + onAvailabilityChanged: changes.add, + ); + await Future.delayed(Duration.zero); + expect(bridge.canSync, isFalse); + var builds = 0; + bridge.schedule( + () { + builds++; + return _snapshot(); + }, + urgencyKeyBuilder: () => _snapshot().urgencyKey, + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 10)); + expect(builds, 0, + reason: 'an iPhone without a watch must not construct geo payloads'); + + final result = await TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(const MethodCall( + 'availabilityChanged', + { + 'supported': true, + 'activated': true, + 'paired': true, + 'installed': true, + 'reachable': true, + }, + )), + null, + ); + expect(result, isNotNull); + expect(bridge.canSync, isTrue); + expect(changes, [true]); + }); + + test('diagnostics identify the exact condition closing the sync gate', + () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + + final result = await TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(const MethodCall( + 'availabilityChanged', + { + 'supported': true, + 'activated': true, + 'paired': true, + 'installed': false, + 'reachable': true, + }, + )), + null, + ); + + expect(result, isNotNull); + expect(bridge.canSync, isFalse); + expect(bridge.diagnostics.value.supported, isTrue); + expect(bridge.diagnostics.value.reachable, isTrue); + expect(bridge.diagnostics.value.failingSyncConditions, ['installed']); + expect(bridge.diagnostics.value.lastAvailabilityChangedAt, isNotNull); + }); + + test('a native false is not cached as a delivered snapshot', () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + syncSucceeds = false; + var builds = 0; + + void schedule() => bridge.schedule( + () { + builds++; + return _snapshot(); + }, + urgencyKeyBuilder: () => _snapshot().urgencyKey, + immediate: true, + ); + + schedule(); + await Future.delayed(const Duration(milliseconds: 10)); + schedule(); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(builds, 2); + expect(syncCalls, 2, + reason: 'native refused both; neither payload was delivered'); + expect(bridge.diagnostics.value.lastSendDelivered, isFalse); + expect(bridge.diagnostics.value.lastSuccessfulSendAt, isNull); + }); + + test('a watch state change resends even when availability stays true', + () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + var builds = 0; + + void schedule() => bridge.schedule( + () { + builds++; + return _snapshot(); + }, + urgencyKeyBuilder: () => _snapshot().urgencyKey, + immediate: true, + ); + + schedule(); + await Future.delayed(const Duration(milliseconds: 10)); + expect(syncCalls, 1); + + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(const MethodCall( + 'availabilityChanged', + { + 'supported': true, + 'activated': true, + 'paired': true, + 'installed': true, + 'reachable': true, + 'nativeCacheCleared': true, + }, + )), + null, + ); + schedule(); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(builds, 2); + expect(syncCalls, 2, + reason: 'native cleared its context cache on the state change'); + }); + + test('a reachability flip alone leaves the caches intact', () async { + // `sessionReachabilityDidChange` fires on every wrist raise and lower, + // and native does not drop `lastContextData` there. Treating it as cache + // invalidation made each glance force a full `updateApplicationContext` + // resend and silently voided the map-geo lease. + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + + void schedule() => bridge.schedule( + _snapshot, + urgencyKeyBuilder: () => _snapshot().urgencyKey, + immediate: true, + ); + + schedule(); + await Future.delayed(const Duration(milliseconds: 10)); + expect(syncCalls, 1); + + for (final reachable in [false, true]) { + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(MethodCall( + 'availabilityChanged', + { + 'supported': true, + 'activated': true, + 'paired': true, + 'installed': true, + 'reachable': reachable, + 'nativeCacheCleared': false, + }, + )), + null, + ); + schedule(); + await Future.delayed(const Duration(milliseconds: 10)); + } + + expect(syncCalls, 1, + reason: 'an unchanged payload must stay deduplicated across glances'); + }); + + // The watch asks for a snapshot after a relaunch, holding a retained + // context it now ages honestly. The phone forgets its delivered fingerprint + // only on a WatchConnectivity state change, and a watch app restart is not + // one — pairing and installation never changed. Without a force path an + // unchanged session answers that request with silence, and the wearer keeps + // looking at state marked stale while the phone is alive and listening. + group('a requested refresh defeats dedupe', () { + // `settle` clears the non-urgent radio interval. A forced refresh is + // deliberately still subject to it, so anything shorter would only prove + // the throttle deferred the flush, not what the flush decided. + Future deliver({ + DateTime? updatedAt, + bool forceDelivery = false, + Duration wait = settle, + }) async { + bridge.schedule( + () => _snapshot(updatedAt: updatedAt), + urgencyKeyBuilder: () => _snapshot(updatedAt: updatedAt).urgencyKey, + immediate: true, + forceDelivery: forceDelivery, + ); + await Future.delayed(wait); + } + + test('an identical payload is still deduplicated without one', () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + + await deliver(); + expect(syncCalls, 1); + + await deliver( + updatedAt: DateTime.fromMillisecondsSinceEpoch(1760000600000), + ); + + expect(syncCalls, 1, + reason: 'a newer updatedAt alone must not spend the radio'); + }); + + test('a forced refresh sends the unchanged payload again', () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + + await deliver(); + expect(syncCalls, 1); + + // Semantically identical to what the watch already holds. Only + // updatedAt moved, which is exactly what proves the phone is alive. + await deliver( + updatedAt: DateTime.fromMillisecondsSinceEpoch(1760000600000), + forceDelivery: true, + ); + + expect(syncCalls, 2, + reason: 'a refresh the watch asked for must reach it'); + }); + + test('the throttle delays a forced refresh but cannot cancel it', + () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + + // The first delivery is unthrottled — nothing has been built yet — so + // the forced one has to follow it closely enough to land inside the + // interval it opens. + await deliver(wait: const Duration(milliseconds: 10)); + expect(syncCalls, 1); + + await deliver( + updatedAt: DateTime.fromMillisecondsSinceEpoch(1760000600000), + forceDelivery: true, + wait: const Duration(milliseconds: 10), + ); + + expect(syncCalls, 1, + reason: 'the radio interval still governs when it goes out'); + + await Future.delayed(settle); + + expect(syncCalls, 2, + reason: 'the obligation must survive its own deferral'); + }); + + test('a native refusal does not consume the obligation', () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + + await deliver(); + expect(syncCalls, 1); + + // Native refuses. The fingerprint is deliberately not updated, so an + // obligation dropped here would dedupe against that same payload on + // every later flush and strand the watch exactly as before the fix. + syncSucceeds = false; + await deliver( + updatedAt: DateTime.fromMillisecondsSinceEpoch(1760000600000), + forceDelivery: true, + ); + expect(syncCalls, 2, + reason: 'the refused attempt still reached native'); + + syncSucceeds = true; + // Note the absent forceDelivery: the obligation has to be the thing + // carrying this through, not a fresh request. + await deliver( + updatedAt: DateTime.fromMillisecondsSinceEpoch(1760000700000), + ); + + expect(syncCalls, 3, + reason: 'a refusal must leave the refresh still owed'); + }); + + test('the obligation is spent, not standing', () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + + await deliver(); + await deliver( + updatedAt: DateTime.fromMillisecondsSinceEpoch(1760000600000), + forceDelivery: true, + ); + expect(syncCalls, 2); + + await deliver( + updatedAt: DateTime.fromMillisecondsSinceEpoch(1760000700000), + ); + + expect(syncCalls, 2, + reason: 'one request buys one delivery, not a permanent bypass'); + }); + }); + + group('a session that ends clears the watch', () { + // The disconnect path: the provider stops producing a snapshot, and the + // bridge must tell native to drop its retained application context so the + // wrist is not left rendering a session that no longer exists. + // + // Untested before the throttle became injectable, because reaching the + // clear meant waiting out the real interval twice. + test('a null snapshot clears native and does not clear twice', () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + + WatchSnapshot? pending = _snapshot(); + void schedule() => bridge.schedule( + () => pending, + urgencyKeyBuilder: () => _snapshot().urgencyKey, + immediate: true, + ); + + schedule(); + await Future.delayed(settle); + expect(syncCalls, 1); + expect(clearCalls, 0); + + pending = null; + schedule(); + await Future.delayed(settle); + + expect(clearCalls, 1, + reason: 'native must forget the context when the session ends'); + + // Still nothing to send, and native has already been reconciled. + schedule(); + await Future.delayed(settle); + + expect(clearCalls, 1, + reason: 'an already-cleared bridge must not keep clearing'); + }); + + test('a snapshot after a clear is delivered, not deduped away', () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + + WatchSnapshot? pending = _snapshot(); + void schedule() => bridge.schedule( + () => pending, + urgencyKeyBuilder: () => _snapshot().urgencyKey, + immediate: true, + ); + + schedule(); + await Future.delayed(settle); + expect(syncCalls, 1); + + pending = null; + schedule(); + await Future.delayed(settle); + expect(clearCalls, 1); + + // The same payload as the first send. The clear dropped the + // fingerprint, so this is new information to a watch holding nothing. + pending = _snapshot(); + schedule(); + await Future.delayed(settle); + + expect(syncCalls, 2, + reason: 'a cleared watch must not be deduped against stale state'); + }); + }); + + test('a burst of notifications coalesces into one build', () async { + // The 200 ms debounce, which nothing covered. It is the first of the five + // suppression gates and the only one that acts on scheduling rather than + // on content, so a regression here would be invisible to every other test + // in this file. + final debounced = WatchBridgeService( + channel: channel, + debounceDelay: const Duration(milliseconds: 60), + minimumNonUrgentInterval: throttle, + ); + addTearDown(debounced.dispose); + debounced.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + + var builds = 0; + for (var i = 0; i < 8; i++) { + debounced.schedule( + () { + builds++; + return _snapshot(); + }, + urgencyKeyBuilder: () => _snapshot().urgencyKey, + ); + } + + expect(builds, 0, reason: 'nothing is built while the burst is arriving'); + + await Future.delayed(const Duration(milliseconds: 200)); + + expect(builds, 1, + reason: 'eight notifications must cost one snapshot, not eight'); + }); + + test('the nonurgent throttle runs before the snapshot builder', () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + var builds = 0; + + void schedule() => bridge.schedule( + () { + builds++; + return _snapshot(); + }, + urgencyKeyBuilder: () => _snapshot().urgencyKey, + immediate: true, + ); + + schedule(); + await Future.delayed(const Duration(milliseconds: 10)); + schedule(); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(builds, 1, + reason: 'geo construction waits until the throttle window opens'); + expect(syncCalls, 1); + }); + + test('an accepted native send is recorded, not called delivered', () async { + // There is deliberately no per-snapshot delivery callback here. Native + // returning true means `updateApplicationContext` accepted the blob; the + // watch may be suspended and ingest it much later, or never. Anything + // that treated this as wearer-visible delivery — the cue lifetime once + // did — dropped state the watch had not yet seen. + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + final snapshot = _snapshot(); + + bridge.schedule( + () => snapshot, + urgencyKeyBuilder: () => snapshot.urgencyKey, + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(syncCalls, 1); + expect(bridge.diagnostics.value.lastSendDelivered, isTrue); + expect(bridge.diagnostics.value.lastSuccessfulSendAt, isNotNull); + }); + + test('accepted commands reach the handler', () async { + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return null; + }); + + final reply = await sendCommand('cmd-1', 'manualPing'); + + expect(handled, [WatchCommandKind.manualPing]); + expect(reply?['accepted'], isTrue); + }); + + test('fresh map demand suppresses and restores geography', () async { + bridge.attachCommandHandler((_) => null); + final nowMs = DateTime.now().millisecondsSinceEpoch.toDouble(); + + await sendCommand( + 'geo-off', + 'requestSnapshot', + mapGeoNeeded: false, + issuedAtMs: nowMs, + ); + expect(bridge.shouldIncludeMapGeo, isFalse); + + await sendCommand( + 'geo-on', + 'requestSnapshot', + mapGeoNeeded: true, + ); + expect(bridge.shouldIncludeMapGeo, isTrue); + }); + + test('an unrenewed suppression lease expires back to full geography', + () async { + // Suppression is a lease, not a latch. The watch renews it every five + // minutes while its map stays hidden; if those renewals stop — the watch + // app dies, the command is lost — the phone has to fail safe toward + // sending too much rather than leaving a future map blank. + // + // Untestable before the interval became injectable: the real lease is ten + // minutes long. + bridge.attachCommandHandler((_) => null); + + await sendCommand( + 'lease-off', + 'requestSnapshot', + mapGeoNeeded: false, + issuedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + expect(bridge.shouldIncludeMapGeo, isFalse, + reason: 'a fresh claim suppresses the expensive payload'); + + await Future.delayed(const Duration(milliseconds: 320)); + + expect(bridge.shouldIncludeMapGeo, isTrue, + reason: 'an unrenewed lease must expire, not persist'); + }); + + test('a renewal keeps the lease alive without the wearer asking', () async { + bridge.attachCommandHandler((_) => null); + + await sendCommand( + 'lease-1', + 'requestSnapshot', + mapGeoNeeded: false, + issuedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + await Future.delayed(const Duration(milliseconds: 150)); + + // The renewal the watch sends every five minutes on the real wire. + await sendCommand( + 'lease-2', + 'requestSnapshot', + mapGeoNeeded: false, + issuedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + await Future.delayed(const Duration(milliseconds: 150)); + + expect(bridge.shouldIncludeMapGeo, isFalse, + reason: 'renewal must reset the lease, or a hidden map still pays'); + }); + + test('stale suppression cannot blank a newly visible map', () async { + bridge.attachCommandHandler((_) => null); + final staleMs = DateTime.now() + .subtract(const Duration(seconds: 45)) + .millisecondsSinceEpoch + .toDouble(); + + await sendCommand( + 'stale-geo-off', + 'requestSnapshot', + mapGeoNeeded: false, + issuedAtMs: staleMs, + ); + + expect(bridge.shouldIncludeMapGeo, isTrue); + }); + + test('an older queued suppression cannot overtake map demand', () async { + bridge.attachCommandHandler((_) => null); + final nowMs = DateTime.now().millisecondsSinceEpoch.toDouble(); + + await sendCommand( + 'new-geo-on', + 'requestSnapshot', + mapGeoNeeded: true, + issuedAtMs: nowMs, + ); + await sendCommand( + 'old-geo-off', + 'requestSnapshot', + mapGeoNeeded: false, + issuedAtMs: nowMs - 1000, + ); + + expect(bridge.shouldIncludeMapGeo, isTrue); + }); + + test('a redelivered command does not transmit twice', () async { + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return null; + }); + + await sendCommand('cmd-1', 'manualPing'); + await sendCommand('cmd-1', 'manualPing'); + + expect(handled, hasLength(1), + reason: 'WatchConnectivity redelivers; a second ping must not fire'); + }); + + test('a redelivered acceptance still acks as accepted', () async { + // The other half of the echo: an accepted command must not start + // reporting itself refused just because the conditions moved on. + var refuse = false; + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return refuse ? 'Not connected' : null; + }); + + final issuedAtMs = DateTime.now().millisecondsSinceEpoch.toDouble(); + final first = await sendCommand( + 'queued-3', + 'manualPing', + issuedAtMs: issuedAtMs, + ); + expect(first?['accepted'], isTrue); + + refuse = true; + final redelivered = await sendCommand( + 'queued-3', + 'manualPing', + issuedAtMs: issuedAtMs, + ); + + expect(handled, hasLength(1)); + expect(redelivered?['accepted'], isTrue); + expect(redelivered?['reason'], isNull); + }); + + test('a refused command may be retried once conditions change', () async { + var refuse = true; + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return refuse ? 'Not connected' : null; + }); + + final first = await sendCommand('cmd-2', 'startSession'); + expect(first?['accepted'], isFalse); + expect(first?['reason'], 'Not connected'); + + refuse = false; + final second = await sendCommand('cmd-2', 'startSession'); + expect(second?['accepted'], isTrue); + expect(handled, hasLength(2)); + }); + + test('a refused queued command is never retried once conditions change', + () async { + // The counterpart to the test above, and the branch that matters on a + // shipping watch: transferUserInfo has no reply to act on a refusal, so + // WatchConnectivity redelivers the same tap on its own schedule. + // Forgetting the ID the way the legacy reply path does would let a stale + // wrist action transmit the moment the phone became willing. + var refuse = true; + final issuedAtMs = DateTime.now().millisecondsSinceEpoch.toDouble(); + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return refuse ? 'Not connected' : null; + }); + + final first = await sendCommand( + 'queued-2', + 'startSession', + issuedAtMs: issuedAtMs, + ); + expect(first?['accepted'], isFalse); + expect(first?['reason'], 'Not connected'); + + refuse = false; + final redelivered = await sendCommand( + 'queued-2', + 'startSession', + issuedAtMs: issuedAtMs, + ); + + expect(handled, hasLength(1), + reason: 'redelivery must never turn yesterday\'s tap into a ' + 'transmit'); + expect(redelivered?['accepted'], isFalse, + reason: 'the ack must describe what happened, not what was hoped'); + expect(redelivered?['reason'], 'Not connected', + reason: 'the recorded outcome is echoed, not recomputed'); + }); + + // transferUserInfo keeps a tapped command alive until the phone is + // reachable again, so the admission window is the only thing stopping a + // queued transmit from firing long after the wearer asked for it. Both + // bounds are load-bearing: "too old" is the queue sitting on it, and "too + // far in the future" is a skewed watch clock buying the same command extra + // life. requestSnapshot is deliberately exempt — it transmits nothing. + group('queued command age', () { + // Stop is absent on purpose: it takes the radio off air, so a late one + // is always the safe direction and is exempt below. + const transmitting = ['startSession', 'manualPing']; + + double nowMs() => DateTime.now().millisecondsSinceEpoch.toDouble(); + + for (final kind in transmitting) { + test('$kind older than 30 seconds is refused', () async { + final refusals = []; + bridge.attachCommandHandler( + (command) async { + handled.add(command.kind); + return null; + }, + onRefusal: refusals.add, + ); + + final reply = await sendCommand( + 'aged-$kind', + kind, + issuedAtMs: nowMs() - const Duration(seconds: 31).inMilliseconds, + ); + + expect(reply?['accepted'], isFalse); + expect(reply?['reason'], externalCommandExpiredReason); + expect(handled, isEmpty, + reason: 'an expired command must never reach admission'); + expect(refusals, [externalCommandExpiredReason], + reason: 'the wearer is told why the tap did nothing'); + }); + + test('$kind dated far into the future is refused', () async { + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return null; + }); + + final reply = await sendCommand( + 'future-$kind', + kind, + issuedAtMs: nowMs() + const Duration(minutes: 10).inMilliseconds, + ); + + expect(reply?['accepted'], isFalse, + reason: 'a fast watch clock must not extend the 30s window'); + expect(handled, isEmpty); + }); + + test('$kind within the clock tolerance is accepted', () async { + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return null; + }); + + final reply = await sendCommand( + 'skewed-$kind', + kind, + issuedAtMs: nowMs() + const Duration(seconds: 2).inMilliseconds, + ); + + expect(reply?['accepted'], isTrue, + reason: 'ordinary skew must not refuse a live tap'); + expect(handled, hasLength(1)); + }); + } + + test('an untimestamped command is still accepted', () async { + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return null; + }); + + final reply = await sendCommand('legacy-1', 'manualPing'); + + expect(reply?['accepted'], isTrue, + reason: 'older watch builds send no issuedAtMs'); + expect(handled, hasLength(1)); + }); + + test('a skewed watch clock degrades, it does not refuse everything', + () async { + // The failure this replaces: two devices that disagree about the time + // by more than the tolerance had every timestamped command refused, for + // as long as the skew lasted. An unshared clock is a normal condition, + // not a reason to make the wrist inert. + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return null; + }); + + // A watch running four minutes fast. Without the offset this stamp is + // 240 s in the future and fails the lower bound outright. + const skew = Duration(minutes: 4); + final reply = await sendCommand( + 'skewed-fast', + 'manualPing', + issuedAtMs: nowMs() + + skew.inMilliseconds - + const Duration(seconds: 2).inMilliseconds, + clockOffsetMs: -skew.inMilliseconds.toDouble(), + ); + + expect(reply?['accepted'], isTrue, + reason: 'a measured offset must make the age a real elapsed time'); + expect(handled, [WatchCommandKind.manualPing]); + }); + + test('the offset corrects the age, it does not excuse a stale command', + () async { + // The window still has to mean something: a genuinely old command is + // still old once the clocks agree, and correcting for skew must not + // become a way to smuggle yesterday's tap onto the radio. + final refusals = []; + bridge.attachCommandHandler( + (command) async { + handled.add(command.kind); + return null; + }, + onRefusal: refusals.add, + ); + + const skew = Duration(minutes: 4); + final reply = await sendCommand( + 'skewed-and-stale', + 'manualPing', + issuedAtMs: nowMs() + + skew.inMilliseconds - + const Duration(seconds: 45).inMilliseconds, + clockOffsetMs: -skew.inMilliseconds.toDouble(), + ); + + expect(reply?['accepted'], isFalse); + expect(reply?['reason'], externalCommandExpiredReason); + expect(handled, isEmpty); + expect(refusals, [externalCommandExpiredReason]); + }); + + test('an older watch build sending no offset behaves exactly as before', + () async { + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return null; + }); + + final fresh = await sendCommand( + 'no-offset-fresh', + 'manualPing', + issuedAtMs: nowMs() - const Duration(seconds: 5).inMilliseconds, + ); + expect(fresh?['accepted'], isTrue); + + final stale = await sendCommand( + 'no-offset-stale', + 'manualPing', + issuedAtMs: nowMs() - const Duration(seconds: 31).inMilliseconds, + ); + expect(stale?['accepted'], isFalse, + reason: 'absent offset must mean zero, not unbounded tolerance'); + }); + + test('an aged requestSnapshot is exempt from the transmit window', + () async { + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return null; + }); + + final reply = await sendCommand( + 'aged-snapshot', + 'requestSnapshot', + issuedAtMs: nowMs() - const Duration(minutes: 5).inMilliseconds, + ); + + expect(reply?['accepted'], isTrue, + reason: 'asking for state transmits nothing and cannot go stale'); + expect(handled, [WatchCommandKind.requestSnapshot]); + }); + + test('an aged stopSession is exempt, because stopping is the safe way', + () async { + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return null; + }); + + final reply = await sendCommand( + 'aged-stop', + 'stopSession', + issuedAtMs: nowMs() - const Duration(minutes: 5).inMilliseconds, + ); + + expect(reply?['accepted'], isTrue, + reason: 'refusing a late stop leaves the radio transmitting'); + expect(handled, [WatchCommandKind.stopSession]); + }); + + test('a stopSession from a fast watch clock is exempt too', () async { + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return null; + }); + + final reply = await sendCommand( + 'future-stop', + 'stopSession', + issuedAtMs: nowMs() + const Duration(minutes: 10).inMilliseconds, + ); + + expect(reply?['accepted'], isTrue, + reason: 'skew cannot make stopping unsafe; it can only delay it'); + expect(handled, [WatchCommandKind.stopSession]); + }); + }); + + // requestSnapshot carries two intents down one wire. Only a genuine plea + // for state may defeat dedupe; the map-geo lease renews as the same command + // every five minutes while the map stays hidden, and forcing those would + // spend the radio exactly where the lease exists to save it. The intent is + // stated rather than inferred from mapGeoNeeded, which the bridge may + // resolve to null when a suppression claim is stale or out of order. + group('refresh intent is explicit', () { + late List received; + + setUp(() { + received = []; + bridge.attachCommandHandler((command) { + received.add(command); + return null; + }); + }); + + test('a stated refresh asks for delivery', () async { + await sendCommand('r-1', 'requestSnapshot', + mapGeoNeeded: true, forceRefresh: true); + + expect(received.single.forceRefresh, isTrue); + }); + + test('a lease renewal does not', () async { + await sendCommand('r-2', 'requestSnapshot', + mapGeoNeeded: false, forceRefresh: false); + + expect(received.single.forceRefresh, isFalse, + reason: 'renewals repeat every five minutes and must stay cheap'); + }); + + test('returning to the map does not force on its own', () async { + // Restoring geography changes the payload by itself, so dedupe is + // already defeated where it matters. Forcing here would only add a + // guaranteed send to a page transition the wearer makes constantly. + await sendCommand('r-3', 'requestSnapshot', mapGeoNeeded: true); + + expect(received.single.forceRefresh, isFalse); + }); + + test('an older watch build without the field never forces', () async { + await sendCommand('r-4', 'requestSnapshot'); + + expect(received.single.forceRefresh, isFalse, + reason: 'absent must mean false, not "assume the expensive thing"'); + }); + }); + + test('an unknown command is refused without reaching the handler', + () async { + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return null; + }); + + final reply = await sendCommand('cmd-3', 'selfDestruct'); + + expect(handled, isEmpty); + expect(reply?['accepted'], isFalse); + }); + + test('a handler that throws refuses rather than crashing the bridge', + () async { + bridge.attachCommandHandler((command) async => throw StateError('boom')); + + final reply = await sendCommand('cmd-4', 'manualPing'); + + expect(reply?['accepted'], isFalse); + expect(reply?['reason'], 'Command failed'); + }); + + test('a stop command carries its session id to admission', () async { + // The bridge is the only place that names the wire key, and the watch + // target has no Swift tests to pin the other end. If `sessionId` were + // renamed here, every production stop would arrive unattributed and be + // admitted against whatever session happened to be running — the exact + // failure the field exists to prevent, reintroduced silently. + WatchCommand? seen; + bridge.attachCommandHandler((command) { + seen = command; + return null; + }); + + await sendCommand('stop-1', 'stopSession', sessionId: 'session-a'); + + expect(seen?.kind, WatchCommandKind.stopSession); + expect(seen?.sessionId, 'session-a'); + }); + + test('a stop from an older watch build names no session', () async { + WatchCommand? seen; + bridge.attachCommandHandler((command) { + seen = command; + return null; + }); + + await sendCommand('stop-2', 'stopSession'); + + expect(seen?.sessionId, isNull); + }); + + test('a start command carries its requested mode to admission', () async { + WatchCommand? received; + bridge.attachCommandHandler((command) { + received = command; + return null; + }); + + final reply = await sendCommand( + 'cmd-mode', + 'startSession', + mode: 'hybrid', + ); + + expect(reply?['accepted'], isTrue); + expect(received?.kind, WatchCommandKind.startSession); + expect(received?.mode, 'hybrid'); + }); + + test('a forbidden requested mode returns the phone refusal', () async { + bridge.attachCommandHandler((command) { + return resolveWatchRequestedStartMode( + requestedMode: command.mode, + isConnected: true, + txAllowed: false, + ).refusal; + }); + + final reply = await sendCommand( + 'cmd-forbidden-mode', + 'startSession', + mode: 'hybrid', + ); + + expect(reply?['accepted'], isFalse); + expect(reply?['reason'], 'Passive Only'); + }); + }); + + /// The gate that stops a parked phone streaming GPS jitter at the watch, and + /// the reason it lives in the transport rather than in the payload builder. + /// + /// It used to be applied by handing the watch a *stale* position until the + /// fix had moved 15 m, which suppressed the send by making the payload + /// identical. A new ping defeats that dedupe by itself, so the packet went + /// out anyway carrying a puck up to 15 m behind the ping beside it. Adam saw + /// it on a walk: "the pings appear ahead of the current location and center". + group('movement gate', () { + late WatchBridgeService bridge; + late MethodChannel channel; + late List> sent; + + /// The throttle is injected rather than waited out. What this group tests + /// is which payloads the movement gate lets through, and that answer does + /// not depend on the wire's interval being two seconds. + const throttle = Duration(milliseconds: 80); + + /// Waiting out the throttle is only necessary when the assertion is that + /// nothing was sent. A fixed wait tuned near that boundary flakes on a + /// loaded machine — this suite has already seen it — so the positive cases + /// poll instead and only "no send" pays a fixed cost. + const suppressionWindow = Duration(milliseconds: 240); + const sendTimeout = Duration(seconds: 8); + + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + channel = const MethodChannel('meshmapper/watch_move_test'); + bridge = WatchBridgeService( + channel: channel, + minimumNonUrgentInterval: throttle, + ); + sent = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'status') { + return { + 'supported': true, + 'activated': true, + 'paired': true, + 'installed': true, + 'reachable': true, + }; + } + if (call.method == 'sync') { + final args = call.arguments as Map; + sent.add(args['payload'] as Map); + return true; + } + return null; + }); + bridge.attachCommandHandler((_) => null); + }); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + bridge.dispose(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + WatchGeo geoAt( + double lat, { + required int fixedAtMs, + List pings = const [], + }) => + WatchGeo( + you: WatchPosition( + lat: lat, + lon: -122.3, + fixedAt: DateTime.fromMillisecondsSinceEpoch(fixedAtMs), + ), + pings: pings, + repeaters: const [], + heard: const [], + linkedRepeaterIds: const [], + ); + + void schedule(WatchGeo geo) { + final snapshot = _snapshot(geo: geo); + bridge.schedule( + () => snapshot, + urgencyKeyBuilder: () => snapshot.urgencyKey, + immediate: true, + ); + } + + /// Polls for the send rather than sleeping a fixed interval. The bridge + /// reschedules instead of sending while the throttle is closed, so the + /// delay is real but its exact length is not the thing under test. + Future pushExpectingSend(WatchGeo geo, int total) async { + schedule(geo); + final deadline = DateTime.now().add(sendTimeout); + while (sent.length < total && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 25)); + } + expect(sent, hasLength(total)); + } + + /// There is nothing to poll for, so this waits past the throttle *and* the + /// retry it schedules: "no send" has to mean suppressed, not deferred. + Future pushExpectingSuppression(WatchGeo geo, int total) async { + schedule(geo); + await Future.delayed(suppressionWindow); + expect(sent, hasLength(total)); + } + + double sentLat(int index) { + final geo = sent[index]['geo'] as Map; + final you = geo['you'] as Map; + return you['lat'] as double; + } + + test('suppresses jitter, but never a payload that is going out anyway', + () async { + // 1e-5 degrees of latitude is about 1.11 m here, so 47.60002 is roughly + // 2 m from the baseline and 47.6003 is roughly 33 m. + await pushExpectingSend(geoAt(47.6, fixedAtMs: 1760000000000), 1); + + // A newer fix time and a couple of metres. This is a stationary phone, + // and it must not reach the radio — including on the retry the throttle + // scheduled, which `settle` has already allowed to fire. + await pushExpectingSuppression( + geoAt(47.60002, fixedAtMs: 1760000030000), 1); + + // The same two metres, now travelling with a ping. The ping alone + // defeats the dedupe, so this send happens either way; the assertion is + // that it carries the *current* fix rather than the last one sent. + await pushExpectingSend( + geoAt( + 47.60002, + fixedAtMs: 1760000060000, + pings: [ + WatchPing( + id: 'rx-1760000060000', + lat: 47.60002, + lon: -122.3, + kind: 'rx', + color: const WatchColor(0, 0, 255), + at: DateTime.fromMillisecondsSinceEpoch(1760000060000), + ), + ], + ), + 2, + ); + expect(sentLat(1), 47.60002, + reason: 'the puck must not lag the ping beside it'); + + // And the gate still opens on its own once the wearer has actually + // moved, with nothing else in the payload changing. + await pushExpectingSend(geoAt(47.6003, fixedAtMs: 1760000090000), 3); + expect(sentLat(2), 47.6003); + }); + + /// The gate can only recognise a change confined to the fix itself, which + /// is why `AppStateProvider._resolveRankingPosition` still holds a lagging + /// position behind every *derived* field. `WatchHeardNode.distanceM` is a + /// full-precision double, so feeding it the live fix would move the payload + /// on every GPS jitter and walk straight past this gate — one send per + /// throttle interval, from a phone sitting on a table. + test('cannot see through a derived field, so derived fields must not move', + () async { + WatchGeo geoWith(double lat, double distanceM) => WatchGeo( + you: WatchPosition( + lat: lat, + lon: -122.3, + fixedAt: DateTime.fromMillisecondsSinceEpoch(1760000000000), + ), + pings: const [], + repeaters: const [], + heard: [ + WatchHeardNode( + id: '4E5D', + typeColor: const WatchColor(0, 1, 0), + at: DateTime.fromMillisecondsSinceEpoch(1760000000000), + distanceM: distanceM, + ), + ], + linkedRepeaterIds: const [], + ); + + await pushExpectingSend(geoWith(47.6, 1423.5), 1); + + // Two metres of jitter, with the distance readout recomputed from it. + await pushExpectingSend(geoWith(47.60002, 1421.3), 2); + }); + + test('measures from the last fix the watch received', () async { + await pushExpectingSend(geoAt(47.6, fixedAtMs: 1760000000000), 1); + + // Two ~11 m steps in the same direction. Each is short of the threshold + // on its own, but the second lands ~22 m from the fix the watch actually + // holds, so it goes. Anchoring on the previous *computed* fix instead + // would suppress a wearer walking away one short step at a time. + await pushExpectingSuppression( + geoAt(47.6001, fixedAtMs: 1760000030000), 1); + await pushExpectingSend(geoAt(47.6002, fixedAtMs: 1760000060000), 2); + expect(sentLat(1), 47.6002); + }); + }); + + group('command queue depth', () { + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + }); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + ServicesBinding.instance.channelBuffers.resize('meshmapper/watch', 1); + }); + + /// Push straight at the channel buffer, the way the engine does for a + /// platform message with no Dart handler yet. + Future push(String id) { + final data = const StandardMethodCodec().encodeMethodCall( + MethodCall('command', {'id': id, 'kind': 'manualPing'}), + ); + final completer = Completer(); + ServicesBinding.instance.channelBuffers.push( + 'meshmapper/watch', + data, + (_) => completer.complete(), + ); + return completer.future; + } + + test('commands arriving before Dart listens are not lost', () async { + // A queued command can launch this process, and the native relay fires + // as soon as WatchConnectivity delivers — before attachCommandHandler + // runs partway through the provider's async init. transferUserInfo hands + // each command over exactly once, so a dropped one is gone: the wrist + // spins for ten seconds and then says nothing. + WatchBridgeService.reserveCommandQueue(); + + unawaited(push('early-1')); + unawaited(push('early-2')); + unawaited(push('early-3')); + + final seen = []; + final bridge = WatchBridgeService( + channel: const MethodChannel('meshmapper/watch'), + ); + addTearDown(bridge.dispose); + bridge.attachCommandHandler((command) { + seen.add(command.kind.name); + return null; + }); + + await Future.delayed(const Duration(milliseconds: 50)); + + expect(seen.length, 3, + reason: 'the default depth of one keeps only the newest'); + }); + }); +} diff --git a/test/services/wire_tag_codec_test.dart b/test/services/wire_tag_codec_test.dart new file mode 100644 index 00000000..43b755a0 --- /dev/null +++ b/test/services/wire_tag_codec_test.dart @@ -0,0 +1,169 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/meshcore/wire_tag_codec.dart'; + +/// Wire-tag codec contract. The tag now encodes the SESSION DATE as well, so a +/// `(region, NNNN, counter)` triple mints a DIFFERENT tag on a different day — +/// this is what makes a recycled daily NNNN globally unique (the "green TX → +/// DEAD tile" fix). The codec MUST stay byte-identical to the PHP +/// `wireTagEncode`/`wireTagDecode` twins — the pinned canonical vectors below +/// are the cross-language wire contract. +void main() { + const key = 'TESTKEY'; + + group('shape', () { + test('tag is "MM:" + 10 base64url chars (13 chars; 7-byte payload)', () { + final body = WireTagCodec.encode('PAR-20260611-0013', 1, key); + expect(body.length, 13); + expect(RegExp(r'^MM:[A-Za-z0-9_-]{10}$').hasMatch(body), isTrue); + }); + + test('same inputs are deterministic', () { + expect( + WireTagCodec.encode('PAR-20260611-0013', 7, key), + WireTagCodec.encode('PAR-20260611-0013', 7, key), + ); + }); + }); + + group('date is encoded (the bug fix)', () { + test('same region/NNNN/counter on DIFFERENT days mint DIFFERENT tags', () { + final jun20 = WireTagCodec.encode('PAE-20260620-0007', 18, key); + final jun21 = WireTagCodec.encode('PAE-20260621-0007', 18, key); + expect(jun20, isNot(jun21), + reason: 'recycled daily NNNN must not collide across days'); + }); + + test('all six WX7RAW counters differ across the two days', () { + for (var c = 18; c <= 23; c++) { + expect( + WireTagCodec.encode('PAE-20260620-0007', c, key), + isNot(WireTagCodec.encode('PAE-20260621-0007', c, key)), + reason: 'counter $c collided across days', + ); + } + }); + }); + + group('key fallback', () { + test('null key == empty-string key, and both differ from a real key', () { + const sid = 'AAR-20260611-0123'; + expect( + WireTagCodec.encode(sid, 5, null), WireTagCodec.encode(sid, 5, '')); + expect(WireTagCodec.encode(sid, 5, null), + isNot(WireTagCodec.encode(sid, 5, key))); + }); + }); + + group('decode recovers region / full date / session# / counter', () { + test('a specific tag round-trips with the date intact', () { + final body = WireTagCodec.encode('PAE-20260621-0007', 18, key); + final d = WireTagCodec.decode(body, key); + expect(d.region, 'PAE'); + expect(d.year, 2026); + expect(d.month, 6); + expect(d.day, 21); + expect(d.sessionNum, 7); + expect(d.counter, 18); + }); + + test('reconstructs the full session_id', () { + final body = WireTagCodec.encode('YOW-20260504-0005', 42, key); + final d = WireTagCodec.decode(body, key); + final rebuilt = + '${d.region}-${d.year.toString().padLeft(4, '0')}${d.month.toString().padLeft(2, '0')}${d.day.toString().padLeft(2, '0')}-${d.sessionNum.toString().padLeft(4, '0')}'; + expect(rebuilt, 'YOW-20260504-0005'); + }); + + test('wrong key yields a different region', () { + final body = WireTagCodec.encode('PAR-20260611-0013', 1, key); + expect(WireTagCodec.decode(body, 'nope').region, isNot('PAR')); + }); + }); + + group('round-trip exactness (dates, NNNN, counters)', () { + test('boundary dates and the 14-bit NNNN / 11-bit counter extremes', () { + final cases = <(String, int)>[ + ('AAA-20200101-0001', 1), // epoch floor of the date field (year 2020) + ( + 'ZZZ-20831231-9999', + 2047 + ), // ceilings: year 2083, NNNN 9999, counter 2047 + ('PAE-20260101-0007', 1), + ('PAE-20261231-0007', 2047), + ('JKG-20260229-0009', 100), // leap day + ]; + for (final (sid, c) in cases) { + final d = WireTagCodec.decode(WireTagCodec.encode(sid, c, key), key); + final p = sid.split('-'); + expect(d.region, p[0]); + expect(d.year, int.parse(p[1].substring(0, 4))); + expect(d.month, int.parse(p[1].substring(4, 6))); + expect(d.day, int.parse(p[1].substring(6, 8))); + expect(d.sessionNum, int.parse(p[2])); + expect(d.counter, c); + } + }); + + test('1..1000 counters all unique and exactly recovered', () { + const sid = 'AAR-20260611-0123'; + final bodies = {}; + for (var c = 1; c <= 1000; c++) { + final body = WireTagCodec.encode(sid, c, key); + bodies.add(body); + final d = WireTagCodec.decode(body, key); + expect(d.region, 'AAR'); + expect(d.year, 2026); + expect(d.month, 6); + expect(d.day, 11); + expect(d.sessionNum, 123); + expect(d.counter, c); + } + expect(bodies.length, 1000); + }); + }); + + // Cross-language canonical vectors — confirmed byte-identical to PHP + // wireTagEncode (run MeshMapper_Server/wire_tag_codec.php on the same inputs). + // These are the wire contract; changing the codec MUST regenerate both sides. + group('canonical vectors (key=TESTKEY)', () { + final vectors = <(String, int), String>{ + ('PAR-20260611-0013', 1): 'MM:YVNPAr5OIw', + ('JKG-20260611-0009', 1): 'MM:_ZqTR9KmUQ', + ('AAR-20260611-0014', 1): 'MM:xJkY4fMf0A', + ('AAR-20260611-0123', 1000): 'MM:yPPRVhdweg', + ('YOW-20260504-0005', 1): 'MM:q2REy6j1xQ', + ('ZZZ-20260101-9999', 2047): 'MM:tbOGo9kJHg', + ('PAE-20260620-0007', 18): 'MM:YPKG3YBefw', + ('PAE-20260621-0007', 18): + 'MM:gCXS1s-0ew', // same NNNN/counter, next day → different tag + ('AAA-20200101-0001', 1): 'MM:mcvZkYjWyw', + ('ZZZ-20831231-9999', 2047): 'MM:lT-SF6aZAw', + }; + vectors.forEach((input, expected) { + test('${input.$1} ping ${input.$2} -> $expected', () { + expect(WireTagCodec.encode(input.$1, input.$2, key), expected); + }); + }); + }); + + group('canonical vectors (empty-key fallback)', () { + final vectors = <(String, int), String>{ + ('PAR-20260611-0013', 1): 'MM:sYVCDwfmyg', + ('JKG-20260611-0009', 1): 'MM:KzK7sA1D2w', + ('AAR-20260611-0014', 1): 'MM:5KWUc6SGLQ', + ('AAR-20260611-0123', 1000): 'MM:XVn2vjKTzQ', + ('YOW-20260504-0005', 1): 'MM:UIcMpMVBng', + ('ZZZ-20260101-9999', 2047): 'MM:f9QkqSCBKg', + ('PAE-20260620-0007', 18): 'MM:NWGgGQHdUg', + ('PAE-20260621-0007', 18): 'MM:bhBWfbLBfg', + ('AAA-20200101-0001', 1): 'MM:GlX5oUogpg', + ('ZZZ-20831231-9999', 2047): 'MM:v7UAnOrSZw', + }; + vectors.forEach((input, expected) { + test('null/empty key, ${input.$1} ping ${input.$2} -> $expected', () { + expect(WireTagCodec.encode(input.$1, input.$2, null), expected); + expect(WireTagCodec.encode(input.$1, input.$2, ''), expected); + }); + }); + }); +} diff --git a/test/utils/coverage_summary_test.dart b/test/utils/coverage_summary_test.dart new file mode 100644 index 00000000..4ec96ec4 --- /dev/null +++ b/test/utils/coverage_summary_test.dart @@ -0,0 +1,431 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/models/repeater.dart'; +import 'package:mesh_mapper/utils/coverage_summary.dart'; + +Repeater _rep(String id, String hexId, double lat, double lon, + {int enabled = 1, String name = 'R'}) => + Repeater( + id: id, + hexId: hexId, + name: name, + lat: lat, + lon: lon, + lastHeard: 0, + enabled: enabled, + hopBytes: 2, + ); + +void main() { + // Target repeater at (45, -75); id 'ab12' (2 bytes), full hex 'ab12cd34'. + final target = _rep('ab12', 'ab12cd34', 45.0, -75.0); + final lookup = RepeaterLookup.fromRepeaters([target], hopBytes: 2); + + group('GridSummary.fromPoints', () { + final points = >[ + // BIDIR, snr 10, noise -100, 0.02° west of the repeater (~1573 m). + { + 'status': 1, + 'local_snr': 10, + 'noisefloor': -100, + 'lat': 45.0, + 'lon': -75.02, + 'heard_repeats': 'ab12(10)[45.0,-75.0]', + }, + // BIDIR, snr 4, noise -110, at the repeater (0 m). + { + 'status': 1, + 'local_snr': 4, + 'noisefloor': -110, + 'lat': 45.0, + 'lon': -75.0, + 'heard_repeats': 'ab12(4)[45.0,-75.0]', + }, + // TX — no local_snr, snr 6 parsed from the heard_repeats token. + { + 'status': 2, + 'lat': 45.0, + 'lon': -75.0, + 'heard_repeats': 'ab12(6)[45.0,-75.0]', + }, + {'status': 5, 'lat': 45.0, 'lon': -75.0}, // RX + {'status': 3, 'lat': 45.0, 'lon': -75.0}, // DEAD + {'status': 3, 'lat': 45.0, 'lon': -75.0}, // DEAD + {'status': 0, 'lat': 45.0, 'lon': -75.0}, // DROP + // DISC matched by full public_key + baked coord in heard_repeats. + { + 'status': 6, + 'public_key': 'ab12cd34', + 'lat': 45.0, + 'lon': -75.0, + 'heard_repeats': '[45.0,-75.0]', + }, + ]; + + final s = GridSummary.fromPoints(points, lookup); + + test('per-status counts + total', () { + expect(s.total, 8); + expect(s.bidir, 2); + expect(s.tx, 1); + expect(s.rx, 1); + expect(s.dead, 2); + expect(s.drop, 1); + expect(s.disc, 1); + }); + + test('AVG SNR averages local_snr + parsed token SNR (10,4,6)', () { + expect(s.avgSnr, isNotNull); + expect(s.avgSnr!, closeTo(20 / 3, 0.001)); + expect(s.snrBucket, 'good'); // > 5 + }); + + test('AVG NOISE averages noisefloor (-100,-110)', () { + expect(s.avgNoise, -105); + }); + + test('MAX DIST is the farthest matched ping→repeater (~1573 m)', () { + expect(s.maxDistMeters, isNotNull); + expect(s.maxDistMeters!, closeTo(1573, 60)); + }); + + test('(0,0) no-location repeater is excluded from MAX DIST', () { + // A repeater at the (0,0) "location not published" sentinel, referenced by + // a baked [0,0] token, must NOT produce a ~8900 km distance to (0,0). + final lk = RepeaterLookup.fromRepeaters( + [target, _rep('00ff', '00ff0000', 0.0, 0.0)], + hopBytes: 2, + ); + final pts = >[ + { + 'status': 1, + 'lat': 45.27, + 'lon': -75.78, + 'heard_repeats': '00ff(5)[0,0]', + }, + ]; + final summary = GridSummary.fromPoints(pts, lk); + expect(summary.bidir, 1); + expect(summary.maxDistMeters, isNull); + }); + + test('MAX DIST resolves a WIDE (full-hex) token + ignores the marker', () { + // The new bake stores tokens WIDER than repeater.id (4-byte cap) + a {U/R} marker. + // 'ab12cd34' (full hex, wider than id 'ab12') must still resolve for MAX DIST — the + // old _byId[token] lookup would have missed it (and shown N/A). + final pts = >[ + { + 'status': 1, + 'lat': 45.0, + 'lon': -75.02, // ~1573 m west of the repeater + 'heard_repeats': 'ab12cd34(8)[45.0,-75.0]{U2}', + }, + ]; + final summary = GridSummary.fromPoints(pts, lookup); + expect(summary.bidir, 1); + expect(summary.maxDistMeters, isNotNull); + expect(summary.maxDistMeters!, closeTo(1573, 60)); + }); + + test('empty input yields a zeroed summary', () { + final e = GridSummary.fromPoints(const [], lookup); + expect(e.total, 0); + expect(e.bidir, 0); + expect(e.avgSnr, isNull); + expect(e.avgNoise, isNull); + expect(e.maxDistMeters, isNull); + expect(e.snrBucket, isNull); + }); + }); + + group('RepeaterStats.fromCoverage', () { + final points = >[ + // BIDIR via heard_repeats token (coords ~repeater); ping ~786 m away. + { + 'status': 1, + 'lat': 45.0, + 'lon': -75.01, + 'heard_repeats': 'ab12(7.5)[45.0,-75.0]', + }, + // TX via the `via` path token. + { + 'status': 2, + 'lat': 45.0, + 'lon': -75.0, + 'via': 'ab12(3.1)[45.0,-75.0]', + }, + { + 'status': 5, + 'lat': 45.0, + 'lon': -75.0, + 'heard_repeats': 'ab12(2.0)[45.0,-75.0]', + }, + { + 'status': 3, + 'lat': 45.0, + 'lon': -75.0, + 'heard_repeats': 'ab12(1.0)[45.0,-75.0]', + }, + // DISC by full public_key; ping ~3931 m away (the max). + { + 'status': 6, + 'public_key': 'ab12cd34', + 'lat': 45.0, + 'lon': -75.05, + 'heard_repeats': '[45.0,-75.0]', + }, + // Token without coords -> must NOT match (dup-logic enabled by default). + { + 'status': 1, + 'lat': 45.5, + 'lon': -75.0, + 'heard_repeats': 'ab12(9)', + }, + // References a different repeater -> must NOT match. + { + 'status': 1, + 'lat': 40.0, + 'lon': -70.0, + 'heard_repeats': 'ff99(9)[40.0,-70.0]', + }, + // DROP -> skipped. + {'status': 0, 'lat': 45.0, 'lon': -75.0}, + ]; + + final stats = RepeaterStats.fromCoverage(points, target, lookup); + + test('per-status counts attributed to the target', () { + expect(stats.bidir, 1); + expect(stats.tx, 1); + expect(stats.rx, 1); + expect(stats.disc, 1); + expect(stats.dead, 1); + expect(stats.totalMatched, 5); + }); + + test('max range is the farthest matched ping (~3931 m)', () { + expect(stats.maxRangeMeters, isNotNull); + expect(stats.maxRangeMeters!, closeTo(3931, 80)); + }); + + test('no matches yields zeros + null range', () { + final none = RepeaterStats.fromCoverage( + const [ + { + 'status': 1, + 'lat': 40.0, + 'lon': -70.0, + 'heard_repeats': 'ff99(9)[40,-70]' + }, + ], + target, + lookup, + ); + expect(none.totalMatched, 0); + expect(none.maxRangeMeters, isNull); + }); + }); + + group('GridCell', () { + // 100 m Detailed grid steps (kCoverageGridSteps[100]). + const latStep = 0.0009, lonStep = 0.00128; + + test('taps anywhere in a cell resolve to the same cell; centre is inside', + () { + final cell = GridCell.containing(45.26970, -75.77795, latStep, lonStep); + final a = + GridCell.containing(cell.centerLat, cell.centerLon, latStep, lonStep); + final b = GridCell.containing(cell.centerLat + latStep * 0.3, + cell.centerLon - lonStep * 0.3, latStep, lonStep); + expect(a.i, cell.i); + expect(a.j, cell.j); + expect(b.i, cell.i); + expect(b.j, cell.j); + expect(cell.contains(cell.centerLat, cell.centerLon), isTrue); + }); + + test('filter keeps only in-cell points (parses string coords)', () { + final cell = GridCell.containing(45.0, -75.0, latStep, lonStep); + final pts = >[ + {'lat': cell.centerLat, 'lon': cell.centerLon}, // in cell (num) + { + 'lat': '${cell.centerLat}', + 'lon': '${cell.centerLon}' + }, // in cell (string) + { + 'lat': (cell.i + 3) * latStep + latStep * 0.5, + 'lon': cell.centerLon + }, // a different cell + {'lat': null, 'lon': cell.centerLon}, // unparseable + ]; + expect(cell.filter(pts).length, 2); + }); + + test('Detailed (blob=1) keeps a ping one cell away; own-cell-only drops it', + () { + // The Detailed (100 m) coverage tile paints a 3×3 block per ping, so a + // green cell can be coloured by a ping up to 1 cell away. Mirrors the web's + // lazyShowPingsAt: own-cell-only would falsely show "no coverage data here". + final cell = GridCell.containing(45.0, -75.0, latStep, lonStep); + final pts = >[ + { + 'lat': (cell.i + 1) * latStep + latStep * 0.5, + 'lon': (cell.j - 1) * lonStep + lonStep * 0.5, + }, // diagonal neighbour — own cell is (i+1, j-1) + { + 'lat': (cell.i + 2) * latStep + latStep * 0.5, + 'lon': cell.centerLon, + }, // two cells away — outside the ±1 blob + {'lat': null, 'lon': cell.centerLon}, // unparseable + ]; + // blob=1: the diagonal neighbour is within ±1; the two-away ping is not. + expect(cell.filterWithinBlob(pts, 1).length, 1); + // own-cell-only (the pre-fix behaviour) drops both → empty summary. + expect(cell.filter(pts).length, 0); + }); + + test('Simplified (blob=0) reduces to own-cell-only filtering', () { + const sLat = 0.0027, sLon = 0.00384; // kCoverageGridSteps[300] + final cell = GridCell.containing(45.0, -75.0, sLat, sLon); + final pts = >[ + {'lat': cell.centerLat, 'lon': cell.centerLon}, // in cell + { + 'lat': (cell.i + 1) * sLat + sLat * 0.5, + 'lon': cell.centerLon + }, // a neighbour cell + ]; + expect(cell.filterWithinBlob(pts, 0).length, cell.filter(pts).length); + expect(cell.filterWithinBlob(pts, 0).length, 1); + }); + + test( + 'blob fetch radius reaches the ±blob block corner, floored at gridSize', + () { + // Detailed: blob=1, 100 m floor → must exceed 100 m and reach the block's + // far corner (~212 m here) so blob-neighbour pings get fetched. + final detailed = GridCell.containing(45.0, -75.0, latStep, lonStep); + final rDetailed = detailed.blobFetchRadiusMeters(1, 100); + expect(rDetailed, greaterThan(100)); + expect(rDetailed, greaterThanOrEqualTo(212)); + + // Simplified: blob=0, the own-cell corner (~212 m) is below the 300 m floor + // → returns exactly 300, byte-unchanged from the old gridSize radius. + const sLat = 0.0027, sLon = 0.00384; + final simplified = GridCell.containing(45.0, -75.0, sLat, sLon); + expect(simplified.blobFetchRadiusMeters(0, 300), 300); + }); + }); + + group('GridCell.blockRing', () { + const latStep = 0.0009; + const lonStep = 0.00128; + const cell = GridCell(10, 20, latStep, lonStep); + + test('blob=1 makes a 3x3 block centred on the tapped cell', () { + final ring = cell.blockRing(1); + expect(ring.length, 5); + expect(ring.first, ring.last); // closed ring + final sw = ring[0]; // [minLon, minLat] + final ne = ring[2]; // [maxLon, maxLat] + // 3 cells wide/tall: i-1..i+2 and j-1..j+2 + expect(sw[1], closeTo(9 * latStep, 1e-12)); + expect(ne[1], closeTo(12 * latStep, 1e-12)); + expect(sw[0], closeTo(19 * lonStep, 1e-12)); + expect(ne[0], closeTo(22 * lonStep, 1e-12)); + // The block's centre is the tapped cell's centre — the clicked tile is + // always the middle. + expect((sw[1] + ne[1]) / 2, closeTo(cell.centerLat, 1e-12)); + expect((sw[0] + ne[0]) / 2, closeTo(cell.centerLon, 1e-12)); + }); + + test('blob=0 is just the tapped cell', () { + final ring = cell.blockRing(0); + final sw = ring[0]; + final ne = ring[2]; + expect(sw[1], closeTo(10 * latStep, 1e-12)); + expect(ne[1], closeTo(11 * latStep, 1e-12)); + expect(sw[0], closeTo(20 * lonStep, 1e-12)); + expect(ne[0], closeTo(21 * lonStep, 1e-12)); + }); + }); + + group('GridCell.blockCellPolygons', () { + const latStep = 0.0009; + const lonStep = 0.00128; + const cell = GridCell(10, 20, latStep, lonStep); + + test('blob=0 yields the single tapped cell', () { + final polys = cell.blockCellPolygons(0); + expect(polys.length, 1); + final ring = polys.first; + expect(ring.length, 5); + expect(ring.first, ring.last); // closed ring, [lon, lat] order + expect(ring[0][0], closeTo(20 * lonStep, 1e-12)); // minLon + expect(ring[0][1], closeTo(10 * latStep, 1e-12)); // minLat + expect(ring[2][0], closeTo(21 * lonStep, 1e-12)); // maxLon + expect(ring[2][1], closeTo(11 * latStep, 1e-12)); // maxLat + }); + + test('blob=1 yields nine unit cells covering the 3x3 block', () { + final polys = cell.blockCellPolygons(1); + expect(polys.length, 9); + final iSet = {}; + final jSet = {}; + for (final ring in polys) { + expect(ring.length, 5); + expect(ring.first, ring.last); + // Each polygon is exactly one grid cell (1 lonStep x 1 latStep). + expect(ring[2][0] - ring[0][0], closeTo(lonStep, 1e-12)); + expect(ring[2][1] - ring[0][1], closeTo(latStep, 1e-12)); + jSet.add((ring[0][0] / lonStep).round()); + iSet.add((ring[0][1] / latStep).round()); + } + // The 3x3 covers exactly i in 9..11 and j in 19..21, centred on (10, 20). + expect(iSet, {9, 10, 11}); + expect(jSet, {19, 20, 21}); + }); + }); + + group('dominantCoverageStatus', () { + Map p(int status, + {String? heardRepeats, String? directHeard}) => + { + 'lat': 45.0, + 'lon': -75.0, + 'status': status, + if (heardRepeats != null) 'heard_repeats': heardRepeats, + if (directHeard != null) 'direct_heard': directHeard, + }; + + test('returns null for no points', () { + expect(dominantCoverageStatus(const []), isNull); + }); + + test('maps each status to its st category (web parity)', () { + expect(dominantCoverageStatus([p(1, heardRepeats: 'ab(5)')]), 1); // green + expect(dominantCoverageStatus([p(1)]), 1); // BIDIR is green either way + expect(dominantCoverageStatus([p(2)]), 3); // TX -> orange + expect(dominantCoverageStatus([p(5)]), 4); // RX -> purple + expect(dominantCoverageStatus([p(6)]), 2); // DISC -> cyan + expect(dominantCoverageStatus([p(7)]), 2); // TRACE -> cyan + expect(dominantCoverageStatus([p(3)]), 5); // DEAD -> grey + expect(dominantCoverageStatus([p(0)]), 6); // DROP -> red + expect(dominantCoverageStatus([p(99)]), 6); // unknown -> red + }); + + test('green wins over red when both are in the blob', () { + expect(dominantCoverageStatus([p(0), p(1)]), 1); // red + green -> green + expect(dominantCoverageStatus([p(1), p(0)]), 1); // order independent + }); + + test('an all-red blob stays red (the intentional smear)', () { + // The green a neighbour cell shows came from a ping OUTSIDE this blob, so + // the in-blob pings are all red -> the whole block resolves to red. + expect(dominantCoverageStatus([p(0), p(0)]), 6); + }); + + test('picks the highest-priority (lowest st) across a mix', () { + expect(dominantCoverageStatus([p(2), p(6), p(3)]), + 2); // orange/cyan/grey -> cyan + }); + }); +} diff --git a/test/utils/coverage_tile_palette_test.dart b/test/utils/coverage_tile_palette_test.dart new file mode 100644 index 00000000..e09c7899 --- /dev/null +++ b/test/utils/coverage_tile_palette_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/utils/coverage_tile_palette.dart'; + +void main() { + group('CoverageTilePalette.colorsForStatus', () { + test('none palette maps st to [fill, border]', () { + expect(CoverageTilePalette.colorsForStatus('none', 1), + ['#1e7e34', '#14522d']); // green + expect(CoverageTilePalette.colorsForStatus('none', 2), + ['#17a2b8', '#117a8b']); // cyan + expect(CoverageTilePalette.colorsForStatus('none', 6), + ['#bd2130', '#8b101b']); // red + }); + + test('unknown cvd mode falls back to none', () { + expect(CoverageTilePalette.colorsForStatus('bogus', 1), + CoverageTilePalette.colorsForStatus('none', 1)); + }); + + test('clamps out-of-range st into 1..6', () { + expect(CoverageTilePalette.colorsForStatus('none', 0), + CoverageTilePalette.colorsForStatus('none', 1)); + expect(CoverageTilePalette.colorsForStatus('none', 99), + CoverageTilePalette.colorsForStatus('none', 6)); + }); + + test('a cvd palette differs from none', () { + expect(CoverageTilePalette.colorsForStatus('protanopia', 1), + isNot(CoverageTilePalette.colorsForStatus('none', 1))); + }); + }); +} diff --git a/test/utils/geo_validation_test.dart b/test/utils/geo_validation_test.dart new file mode 100644 index 00000000..b1f6e87e --- /dev/null +++ b/test/utils/geo_validation_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/utils/geo_validation.dart'; + +void main() { + group('isValidLatLng', () { + test('accepts normal coordinates', () { + expect(isValidLatLng(45.4215, -75.6972), isTrue); // Ottawa + expect(isValidLatLng(0, 0), isTrue); + expect(isValidLatLng(-33.8688, 151.2093), isTrue); // Sydney + }); + + test('accepts exact domain edges', () { + expect(isValidLatLng(90, 180), isTrue); + expect(isValidLatLng(-90, -180), isTrue); + }); + + test('rejects NaN', () { + expect(isValidLatLng(double.nan, 0), isFalse); + expect(isValidLatLng(0, double.nan), isFalse); + expect(isValidLatLng(double.nan, double.nan), isFalse); + }); + + test('rejects infinity', () { + expect(isValidLatLng(double.infinity, 0), isFalse); + expect(isValidLatLng(0, double.negativeInfinity), isFalse); + }); + + test('rejects out-of-range latitude', () { + expect(isValidLatLng(91, 0), isFalse); + expect(isValidLatLng(-91, 0), isFalse); + expect(isValidLatLng(200, 0), isFalse); + }); + + test('rejects out-of-range longitude', () { + expect(isValidLatLng(0, 181), isFalse); + expect(isValidLatLng(0, -181), isFalse); + expect(isValidLatLng(0, 200), isFalse); + }); + }); + + group('isDegenerateBounds', () { + test('true for identical / coincident points', () { + expect(isDegenerateBounds(45.0, 45.0, -75.0, -75.0), isTrue); + // Sub-0.1m jitter still counts as degenerate. + expect(isDegenerateBounds(45.0, 45.0000001, -75.0, -75.0000001), isTrue); + }); + + test('false for a real spread', () { + expect(isDegenerateBounds(45.0, 45.1, -75.0, -75.1), isFalse); + // Degenerate on one axis only is still a fit-able line. + expect(isDegenerateBounds(45.0, 45.0, -75.0, -75.1), isFalse); + expect(isDegenerateBounds(45.0, 45.1, -75.0, -75.0), isFalse); + }); + }); + + group('clampFitPadding', () { + test('passes through padding that fits', () { + final p = clampFitPadding(60, 60, 60, 60, 400, 800); + expect(p.left, 60); + expect(p.top, 60); + expect(p.right, 60); + expect(p.bottom, 60); + }); + + test('shrinks oversized vertical padding to keep the map visible', () { + // bottom alone exceeds the height budget (the reported crash shape). + final p = clampFitPadding(60, 60, 60, 1000, 400, 800); + expect(p.top + p.bottom, lessThanOrEqualTo(800 - 40)); + expect(p.top, greaterThan(0)); + expect(p.bottom, greaterThan(0)); + // Horizontal axis untouched. + expect(p.left, 60); + expect(p.right, 60); + }); + + test('zero/negative budget yields no padding on that axis', () { + final p = clampFitPadding(60, 60, 60, 60, 400, 30); + expect(p.top, 0); + expect(p.bottom, 0); + }); + + test('unknown/zero viewport returns small safe defaults', () { + final p = clampFitPadding(60, 60, 60, 1000, 0, 0); + expect(p.left, 8); + expect(p.top, 8); + expect(p.right, 8); + expect(p.bottom, 8); + }); + + test('non-finite padding is treated as zero', () { + final p = clampFitPadding(double.nan, 60, double.infinity, 60, 400, 800); + expect(p.left, 0); + expect(p.right, 0); + expect(p.top, 60); + expect(p.bottom, 60); + }); + }); +} diff --git a/test/utils/map_style_errors_test.dart b/test/utils/map_style_errors_test.dart new file mode 100644 index 00000000..2640bcdf --- /dev/null +++ b/test/utils/map_style_errors_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/utils/map_style_errors.dart'; + +/// #482: a duplicate-install race left the native gps-puck layer alive while +/// the Dart-side "installed" flag stayed false, so every retry re-threw +/// "already exists" and the puck was never repositioned again. The classifier +/// lets install paths adopt the existing native object instead of failing. +void main() { + test('recognises the Android CannotAddLayerException shape', () { + final e = PlatformException( + code: 'error', + message: 'Layer gps-puck-layer already exists', + details: 'org.maplibre.android.style.layers.CannotAddLayerException', + ); + expect(mapStyleObjectAlreadyExists(e), isTrue); + }); + + test('recognises the Android CannotAddSourceException shape', () { + final e = PlatformException( + code: 'error', + message: 'Source meshmapper-coverage-patch already exists', + details: 'org.maplibre.android.style.sources.CannotAddSourceException', + ); + expect(mapStyleObjectAlreadyExists(e), isTrue); + }); + + test('does not match unrelated platform errors', () { + final e = PlatformException( + code: 'error', + message: 'Style is not fully loaded', + ); + expect(mapStyleObjectAlreadyExists(e), isFalse); + expect(mapStyleObjectAlreadyExists(StateError('boom')), isFalse); + }); +} diff --git a/test/utils/mvt_cells_test.dart b/test/utils/mvt_cells_test.dart new file mode 100644 index 00000000..cf293d15 --- /dev/null +++ b/test/utils/mvt_cells_test.dart @@ -0,0 +1,46 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/utils/mvt_cells.dart'; + +/// Fixtures generated by the SERVER's dev/mvt_encoder.php — the decoder must +/// stay in lockstep with it (see MeshMapper_Server/docs/VECTOR_TILES.md). +/// +/// `_knownTile`: layer "coverage", extent 4096, two features: +/// id=42 {i:-5, j:7, st:1} and id=43 {i:3, j:7, st:6}. +/// `_realTile`: an actual rendered z12 coverage tile (29 cells). +const _knownTile = + 'GmUKCGNvdmVyYWdlEhkIKhIGAAABAQICGAMiCwkAABoUAAAUEwAPEhsIKxIGAAMBAQIEGAMiDQnIAZADGhQAABQTAA8aAWkaAWoaAnN0IgIwCSICMA4iAjACIgIwBiICMAwogCB4Ag=='; +const _realTile = + 'GuoJCghjb3ZlcmFnZRIjCM2lwuWVPxIGAAABAQICGAMiEAmMPJAgGuYCAAC6AuUCAA8SIwjOpcLllT8SBgAAAQMCAhgDIhAJ8j6QIBrmAgAAugLlAgAPEiMIzKXC5JU/EgYABAEFAgIYAyIQCaY5yiIa5gIAALoC5QIADxIjCM6lwuSVPxIGAAQBAwICGAMiEAnyPsoiGuYCAAC6AuUCAA8SIwjNpcLjlT8SBgAGAQECBxgDIhAJjDyEJRrmAgAAugLlAgAPEiMIzKXC4pU/EgYACAEFAgIYAyIQCaY5vica5gIAALoC5QIADxIjCM2lwuKVPxIGAAgBAQICGAMiEAmMPL4nGuYCAAC6AuUCAA8SIwjDpcLhlT8SBgAJAQoCCxgDIhAJkCD4KRrmAgAAugLlAgAPEiMIy6XC4ZU/EgYACQEMAgsYAyIQCcA2+Cka5gIAALoC5QIADxIjCMOlwuCVPxIGAA0BCgIHGAMiEAmQILIsGuYCAAC6AuUCAA8SIwjEpcLglT8SBgANAQ4CCxgDIhAJ9iKyLBrmAgAAugLlAgAPEiMIy6XC4JU/EgYADQEMAg8YAyIQCcA2siwa5gIAALoC5QIADxIjCM2lwuCVPxIGAA0BAQILGAMiEAmMPLIsGuYCAAC6AuUCAA8SIwjNpcLflT8SBgAQAQECCxgDIhAJjDzsLhrmAgAAugLlAgAPEiMIyqXC3pU/EgYAEQESAg8YAyIQCdozpjEa5gIAALoC5QIADxIjCMylwt6VPxIGABEBBQILGAMiEAmmOaYxGuYCAAC6AuUCAA8SIwjNpcLelT8SBgARAQECBxgDIhAJjDymMRrmAgAAugLlAgAPEiMIyqXC3ZU/EgYAEwESAgIYAyIQCdoz4DMa5gIAALoC5QIADxIjCMylwt2VPxIGABMBBQILGAMiEAmmOeAzGuYCAAC6AuUCAA8SIwjJpcLclT8SBgAUARUCCxgDIhAJ9DCaNhrmAgAAugLlAgAPEiMIyqXC3JU/EgYAFAESAgsYAyIQCdozmjYa5gIAALoC5QIADxIjCMulwtyVPxIGABQBDAILGAMiEAnANpo2GuYCAAC6AuUCAA8SIwjGpcLblT8SBgAWARcCCxgDIhAJwijUOBrmAgAAugLlAgAPEiMIx6XC25U/EgYAFgEYAgcYAyIQCagr1Dga5gIAALoC5QIADxIjCMilwtuVPxIGABYBGQILGAMiEAmOLtQ4GuYCAAC6AuUCAA8SIwjKpcLblT8SBgAWARICDxgDIhAJ2jPUOBrmAgAAugLlAgAPEiMIy6XC25U/EgYAFgEMAgsYAyIQCcA21Dga5gIAALoC5QIADxIjCMqlwtmVPxIGABoBEgICGAMiEAnaM8g9GuYCAAC6AuUCAA8SIwjJpcLYlT8SBgAbARUCAhgDIhAJ9DCCQBrmAgAAugLlAgAPGgFpGgFqGgJzdCIEMLXUASIEMJrLBCICMAwiBDCcywQiBDC31AEiBDCYywQiBDC51AEiAjAIIgQwu9QBIgQwvdQBIgQwhssEIgIwBCIEMJbLBCIEML/UASIEMIjLBCICMAIiBDDB1AEiBDDD1AEiBDCUywQiBDDF1AEiBDDH1AEiBDCSywQiBDDJ1AEiBDCMywQiBDCOywQiBDCQywQiBDDN1AEiBDDP1AEogCB4Ag=='; + +void main() { + test('decodes the known two-feature tile exactly', () { + final cells = decodeCoverageCells(base64Decode(_knownTile)); + expect(cells.length, 2); + expect(cells[0].id, 42); + expect(cells[0].i, -5); + expect(cells[0].j, 7); + expect(cells[0].st, 1); + expect(cells[1].id, 43); + expect(cells[1].i, 3); + expect(cells[1].j, 7); + expect(cells[1].st, 6); + }); + + test('decodes a real rendered tile with valid ids and categories', () { + final cells = decodeCoverageCells(base64Decode(_realTile)); + expect(cells.length, 29); + for (final cell in cells) { + expect(cell.st, inInclusiveRange(1, 6)); + // id = ((i + 2^20) << 21) | (j + 2^20), computed arithmetically here + // because dart2js truncates >32-bit bitwise ops (web build). + final expectedId = (cell.i + 1048576) * 2097152 + (cell.j + 1048576); + expect(cell.id, expectedId); + } + }); + + test('garbage input decodes to empty, never throws', () { + expect(decodeCoverageCells(base64Decode('aGVsbG8gd29ybGQ=')), isEmpty); + }); +} diff --git a/test/utils/pkce_test.dart b/test/utils/pkce_test.dart new file mode 100644 index 00000000..b6bbf901 --- /dev/null +++ b/test/utils/pkce_test.dart @@ -0,0 +1,73 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/utils/pkce.dart'; + +/// PKCE contract for the MyMeshMapper portal sign-in lane. The server rejects +/// anything that is not S256 and not exactly 43 base64url chars, so the shape +/// assertions below are the wire contract, not style preferences. +void main() { + group('challengeFor', () { + test('matches the RFC 7636 Appendix B vector', () { + expect( + PkcePair.challengeFor('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'), + 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', + ); + }); + + test('is deterministic', () { + expect(PkcePair.challengeFor('abc'), PkcePair.challengeFor('abc')); + }); + }); + + group('generate', () { + test('verifier is 43 base64url chars with no padding', () { + final pair = PkcePair.generate(); + expect(pair.verifier.length, 43); + expect(pair.verifier.contains('='), isFalse); + expect(RegExp(r'^[A-Za-z0-9._~-]+$').hasMatch(pair.verifier), isTrue); + expect(RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(pair.verifier), isTrue); + }); + + test('challenge is 43 base64url chars with no padding', () { + final pair = PkcePair.generate(); + expect(pair.challenge.length, 43); + expect(pair.challenge.contains('='), isFalse); + expect(RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(pair.challenge), isTrue); + }); + + test('challenge is the S256 of the verifier it shipped with', () { + final pair = PkcePair.generate(); + expect(pair.challenge, PkcePair.challengeFor(pair.verifier)); + }); + + test('state is an independent 22-char base64url value', () { + final pair = PkcePair.generate(); + expect(pair.state.length, 22); + expect(pair.state.contains('='), isFalse); + // Must satisfy the server's state charset: [A-Za-z0-9._~-]{1,128} + expect(RegExp(r'^[A-Za-z0-9._~-]{1,128}$').hasMatch(pair.state), isTrue); + expect(pair.state, isNot(pair.verifier)); + }); + + test('successive pairs are unique', () { + final verifiers = {}; + final states = {}; + for (var i = 0; i < 50; i++) { + final pair = PkcePair.generate(); + verifiers.add(pair.verifier); + states.add(pair.state); + } + expect(verifiers.length, 50); + expect(states.length, 50); + }); + + test('an injected Random makes generation reproducible', () { + final a = PkcePair.generate(random: Random(42)); + final b = PkcePair.generate(random: Random(42)); + expect(a.verifier, b.verifier); + expect(a.challenge, b.challenge); + expect(a.state, b.state); + }); + }); +} diff --git a/test/utils/repeater_format_test.dart b/test/utils/repeater_format_test.dart new file mode 100644 index 00000000..891271a7 --- /dev/null +++ b/test/utils/repeater_format_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/utils/distance_formatter.dart'; +import 'package:mesh_mapper/utils/repeater_format.dart'; + +void main() { + group('formatDateOnly / daysAgo', () { + test('formats a date as MM/DD/YY (local)', () { + final secs = DateTime(2026, 6, 16).millisecondsSinceEpoch ~/ 1000; + expect(formatDateOnly(secs), '06/16/26'); + }); + + test('accepts millisecond timestamps too', () { + final ms = DateTime(2026, 1, 2).millisecondsSinceEpoch; + expect(formatDateOnly(ms), '01/02/26'); + }); + + test('null / non-positive -> N/A', () { + expect(formatDateOnly(null), 'N/A'); + expect(formatDateOnly(0), 'N/A'); + }); + + test('daysAgo: Today / N days ago', () { + final now = DateTime.now(); + final today = now.millisecondsSinceEpoch ~/ 1000; + final twoDays = + now.subtract(const Duration(days: 2)).millisecondsSinceEpoch ~/ 1000; + expect(daysAgo(today), 'Today'); + expect(daysAgo(twoDays), '2 days ago'); + }); + + test('formatDateWithAgo combines both', () { + final secs = DateTime(2026, 6, 16).millisecondsSinceEpoch ~/ 1000; + expect(formatDateWithAgo(secs), startsWith('06/16/26 (')); + }); + }); + + group('humanizeClockSkew', () { + test('null + within tolerance -> null', () { + expect(humanizeClockSkew(null), isNull); + expect(humanizeClockSkew(60), isNull); + expect(humanizeClockSkew(-120), isNull); + }); + + test('minutes, ahead vs behind', () { + // -2964 s = 49.4 min ahead (matches the spec example). + expect(humanizeClockSkew(-2964), '49.4 minutes ahead'); + expect(humanizeClockSkew(2964), '49.4 minutes behind'); + }); + + test('hours + days', () { + expect(humanizeClockSkew(7200), '2.0 hours behind'); + expect(humanizeClockSkew(-172800), '2.0 days ahead'); + }); + }); + + group('formatCoverageDistance (web parity)', () { + test('metric km + m', () { + expect(formatCoverageDistance(123260), '123.26 km'); + expect(formatCoverageDistance(150), '150 m'); + }); + + test('imperial mi + ft', () { + // 2000 m = 6561.68 ft = 1.2427 mi + expect(formatCoverageDistance(2000, isImperial: true), '1.24 mi'); + expect(formatCoverageDistance(100, isImperial: true), '328 ft'); + }); + }); +} diff --git a/test/utils/serial_task_gate_test.dart b/test/utils/serial_task_gate_test.dart new file mode 100644 index 00000000..cc3611ed --- /dev/null +++ b/test/utils/serial_task_gate_test.dart @@ -0,0 +1,95 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/utils/serial_task_gate.dart'; + +/// #495: concurrent MapLibre style mutations (zone-transfer overlay rebuild +/// racing the post-upload patch refresh) double-added the coverage patch +/// source. The gate serializes those mutation flows; these tests pin down the +/// semantics the map code relies on. +void main() { + test('tasks run strictly in submission order, never overlapping', () { + fakeAsync((async) { + final gate = SerialTaskGate(); + final events = []; + var running = 0; + var maxConcurrent = 0; + + Future task(String name, Duration length) async { + running++; + if (running > maxConcurrent) maxConcurrent = running; + events.add('$name start'); + await Future.delayed(length); + events.add('$name end'); + running--; + } + + gate.run(() => task('a', const Duration(milliseconds: 30))); + gate.run(() => task('b', const Duration(milliseconds: 10))); + gate.run(() => task('c', const Duration(milliseconds: 20))); + async.elapse(const Duration(milliseconds: 100)); + + expect(maxConcurrent, 1, + reason: 'a queued task must never start before the previous ended'); + expect(events, [ + 'a start', + 'a end', + 'b start', + 'b end', + 'c start', + 'c end', + ]); + }); + }); + + test('a task result is returned to its caller', () { + fakeAsync((async) { + final gate = SerialTaskGate(); + int? result; + gate.run(() async => 42).then((v) => result = v); + async.flushMicrotasks(); + expect(result, 42); + }); + }); + + test('a throwing task fails its own caller but not the queue', () { + fakeAsync((async) { + final gate = SerialTaskGate(); + Object? error; + var laterRan = false; + + gate.run(() async { + throw StateError('boom'); + }).then((_) {}, onError: (Object e) { + error = e; + }); + gate.run(() async { + laterRan = true; + }); + async.flushMicrotasks(); + + expect(error, isA(), + reason: 'the error must reach the task submitter'); + expect(laterRan, isTrue, + reason: 'one failed task must not poison the tasks behind it'); + }); + }); + + test('a task queued from within a running task does not deadlock', () { + fakeAsync((async) { + final gate = SerialTaskGate(); + final events = []; + + gate.run(() async { + events.add('outer start'); + // NOT awaited: awaiting a nested enqueue from inside the gate would + // deadlock by design (it runs only after the current task finishes). + gate.run(() async => events.add('inner')); + await Future.delayed(const Duration(milliseconds: 10)); + events.add('outer end'); + }); + async.elapse(const Duration(milliseconds: 50)); + + expect(events, ['outer start', 'outer end', 'inner']); + }); + }); +} diff --git a/third_party/flutter_carplay/CHANGELOG.md b/third_party/flutter_carplay/CHANGELOG.md new file mode 100644 index 00000000..afb05e6f --- /dev/null +++ b/third_party/flutter_carplay/CHANGELOG.md @@ -0,0 +1,202 @@ +## 1.6.5 - 2026-08-21 + +- Fix Android Auto list template startup by validating selectable lists before they reach the host (#120) (ty @JulianBissekkou) +- Complete Android Auto method-channel calls when coroutine handlers throw (#133, #134) (ty @JulianBissekkou) +- Restore the README star history chart (#135) + +## 1.6.4 - 2026-07-06 + +- Move the Android Auto message template docs into the Android Auto API usage section (#129) (ty @EArminjon) + +## 1.6.3 - 2026-06-22 + +- Fix Android Auto example release launches by allowing root template refresh before the car screen is attached (#127, #128) (ty @deandreamatias) +- Keep the Android Auto selectable list demo in its own template so it follows host validation rules (#128) + +## 1.6.2 - 2026-06-16 + +- Support setting `CPSearchTemplate` as the CarPlay root template. +- Add a Search Template row to the example app and a README preview image. +- Document CarPlay entitlement options while keeping parking as the example default for Point of Interest support. + +## 1.6.1 - 2026-06-15 + +- Fix Swift Package Manager builds by keeping `FCPSearchTemplate.swift` inside the SwiftPM source path (#123, #124) (ty @Gabriellsp) +- Reject unsupported `CPSearchTemplate` root templates before CarPlay receives an invalid root template. + +## 1.6.0 - 2026-06-13 + +- Add Swift Package Manager support for iOS package consumers (#111) (ty @justinbeatz) +- Keep `FCPImageTint.swift` in the SwiftPM source path and align CocoaPods source files with the new package layout (#111) (ty @justinbeatz) + +## 1.5.1 + +- Fix CarPlay tab templates when the same list item ID appears in multiple tabs (#121) (ty @Gabriellsp) + +## 1.5.0 + +- Add Android Auto alert, grid, and tab bar templates (#102) (ty @Gabriellsp) +- Add Android Auto modal alert presentation and dismissal APIs (#102) (ty @Gabriellsp) +- Add Android Auto list loading messages and empty view titles (#102) (ty @Gabriellsp) + +## 1.4.0 + +- Add `CPSearchTemplate` support for CarPlay search flows (#96) (ty @sINFdorako) +- Add Android Auto list item, section, and template IDs, including selection and toggle handling (#105) (ty @JulianBissekkou) +- Add Flutter asset SVG support across CarPlay and Android Auto image fields (#112) (ty @JulianBissekkou) +- Add Android Auto `AAMessageTemplate` and `AALongMessageTemplate` support with update APIs (#116) (ty @JulianBissekkou) +- Add Android Auto `AAPaneTemplate` support with update APIs (#119) (ty @JulianBissekkou) +- Clarify the difference between Android Auto and Android Automotive OS in the README (#115) (ty @JulianBissekkou) + +## 1.3.3 + +- Expose `onPop` callbacks on pushed CarPlay templates (#106) (ty @sINFdorako) +- Add Android Auto list item, section, and template IDs (#103) (ty @JulianBissekkou) + +## 1.3.2 + +- Avoid iOS crash around image management (#101) (ty @EArminjon) + +## 1.3.1 + +- Fix crash on iOS 18 and earlier when `CPListImageRowItem` is truncated by the host and fewer image slots are available than requested (#98) (ty @EArminjon) +- Add update methods for CPInformationTemplate items and actions (#97) (ty @sINFdorako) +- Document `CPListImageRowItem` support and `CPInformationTemplate` update methods in the README +- Update the security policy supported versions to include `1.3.x` + +## 1.3.0 + +- Implement CPListImageRowItem with full support for cardElements, condensedElements, elements, gridElements, and imageGridElements (#94) (ty @EArminjon) +- Support individual update for CPListImageRowItem and CPListImageRowItemElement +- Expose tabTitle, systemIcon, and showsTabBadges on CPTabBarTemplate +- Rename enums and classes to be 1:1 with Apple documentation (breaking change) +- Rework UUID logic to allow custom elementId on all models +- Expose convenient update methods on CPListItem and other templates +- Improve image loading error handling with proper error callbacks +- Fix app crash when images are empty string +- Add missing GridButton handler as optional (not required) +- Update uuid dependency to 4.5.3 + +## 1.2.11 + +- Fix `List` is not a subtype of `Iterable` in `updateTemplates` (#92) (ty @EArminjon) +- Fix failed cast on `updateCPListItem` when item is not in root template (#91) (ty @RedC4ke) + +## 1.2.10 + +- Add `@objc(FlutterCarPlaySceneDelegate)` annotation for iOS 26 compatibility (#87) + +This enables apps to reference the delegate class as `flutter_carplay.FlutterCarPlaySceneDelegate` in their scene manifest configuration. Required for runtime class discovery via `NSClassFromString`. Thanks @APIUM! + +## 1.2.9 + +- Add missing `@available(iOS 14.0, *)` annotations to `makeUIImage` and `loadUIImageAsync` (#84) + +These functions reference `SwiftFlutterCarplayPlugin` which requires iOS 14.0+, so Swift requires the availability annotation to propagate. This was missing since v1.2.5. + +## 1.2.8 + +- Fix build failure on Xcode without iOS 26 SDK (follow up to #84) + +The v1.2.7 fix using `#if compiler(>=6.0)` didn't work because Swift 6.0 shipped with Xcode 16 (iOS 18), before iOS 26. Now uses dynamic selector invocation to avoid compile time symbol lookup for `updateImage`. + +## 1.2.7 + +- Fix build failure on Xcode versions without iOS 26 SDK (#84) + +The `CPGridButton.updateImage()` API introduced in 1.2.5 is only available in iOS 26+. This caused compile errors on older Xcode versions since `#available` only handles runtime checks, not compile time SDK availability. iOS 26 specific code is now wrapped in `#if compiler(>=6.0)` to ensure older toolchains skip it entirely. + +- Fix type mismatch in `updateTabBarTemplates` that prevented compilation + +## 1.2.6 + +- Fix compatibility with Dart's `--obfuscate` flag by using explicit type checks instead of `runtimeType.toString()` (fixes #28) +- Add security policy (SECURITY.md) + +## 1.2.5 + +- Fix main thread image loading crash for CPListItem and CPGridButton in https://github.com/oguzhnatly/flutter_carplay/pull/79 (ty @EArminjon) + +This fixes a crash caused by creating UIImage on background threads. Network images are now loaded asynchronously using URLSession, and placeholder images are shown until the actual image loads. For iOS 26+, CPGridButton uses the new `updateImage()` API for async updates. + +## 1.2.4 + +- Fix file URI percent encoding for album art paths with spaces in https://github.com/oguzhnatly/flutter_carplay/pull/82 (ty @APIUM) +- Add `sectionIndexEnabled` option to CPListTemplate for hiding section index letters in https://github.com/oguzhnatly/flutter_carplay/pull/83 (ty @APIUM) + +## 1.2.3 + +- Update tab bar template to support mixed template types in https://github.com/oguzhnatly/flutter_carplay/pull/81 + +This enhances the tab bar template by enabling support for multiple template types (not just list templates) as tab bar children. Supported template types: CPListTemplate, CPPointOfInterestTemplate, CPGridTemplate, CPInformationTemplate (ty @shihabkandil). + +## 1.2.2 + +**Issues:** +Calling `updateTemplates` or `updateSections` updates the layout correctly when the CarPlay is already active, but fail to do when CarPlay not yet started. Using `updateTemplates` or `updateSections` doesn’t refresh ListItem's handler properly, causing missing callbacks. This results in items showing a loading indicator for several seconds because the end event never fires. + +It's been updated by @EArminjon in https://github.com/oguzhnatly/flutter_carplay/pull/77 + +**Fixes :** +- Ensure `updateTemplate` and `updateSections` correctly refresh all relevant data and update the `final _super.handler`. +- Reformatted the code. +- Reuse existing `CPTemplate` instances instead of recreating them. +- Renamed variables to improve clarity. + +## 1.2.1 + +- Update tabBar templates in https://github.com/oguzhnatly/flutter_carplay/pull/71 + +This allow updating a tabBar without removing entire stack. This is useful to add, update or remove tabs. + +**Bug fixes :** +- Ensure that updateSections only recreate necessary entries. +- Ensure that updateSections take and memorise new entries (by using List.from). + +## 1.2.0 + +- Add early support for Android Auto under a new controller `FlutterAndroidAuto`. Not all features are supported yet, see the README for more details. ([#71](https://github.com/oguzhnatly/flutter_carplay/pull/71)) (ty @EArminjon). +- History have been reworked to ensure that all templates are well ordered, presents and synchronized. +- Rename some classes to avoid confusion between Android Auto and CarPlay (breaking change) + - `CPConnectionStatusTypes` -> `ConnectionStatusTypes` + - `CPEnumUtils` -> `EnumUtils` + +## 1.1.3 + +- Documentation and packaging improvements +- Automated publishing setup with GitHub Actions + +## 1.1.1 + +- Add automated publishing support to pub.dev + +## 1.1.0 + +- Add showNowPlaying, it can be called multiple times safely ([#33](https://github.com/oguzhnatly/flutter_carplay/issues/33)) (ty @vanlooverenkoen, @EArminjon) +- Add support for HTTP(s) images (ty @vanlooverenkoen) +- Add support to launch CarPlay without manually launch the iOS app ([#25](https://github.com/oguzhnatly/flutter_carplay/pull/25)) (ty @vanlooverenkoen) +- Update the iOS integration and its doc to fix various issues ([#17](https://github.com/oguzhnatly/flutter_carplay/issues/17), [#35](https://github.com/oguzhnatly/flutter_carplay/issues/35), [#38](https://github.com/oguzhnatly/flutter_carplay/issues/38), [#61](https://github.com/oguzhnatly/flutter_carplay/issues/61), [#67](https://github.com/oguzhnatly/flutter_carplay/issues/67)) (ty @EArminjon, @snipd-mikel) + +## 1.0.3 + +- Build fix for the issue [#7](https://github.com/oguzhnatly/flutter_carplay/issues/7) + +## 1.0.2+1 + +- Point Of Interest and Information Template added. Previews added to README.md. + +## 1.0.2 + +- Point Of Interest and Information Template added. + +## 1.0.1 + +- CarPlay List Template issue #4 fixed. + +## 1.0.0+1 + +- Initial release of Flutter Apple CarPlay Package. Previews added to README.md. + +## 1.0.0 + +- Initial release of Flutter Apple CarPlay Package. diff --git a/third_party/flutter_carplay/LICENSE b/third_party/flutter_carplay/LICENSE new file mode 100644 index 00000000..1ae99106 --- /dev/null +++ b/third_party/flutter_carplay/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Oğuzhan Atalay + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/third_party/flutter_carplay/README.md b/third_party/flutter_carplay/README.md new file mode 100644 index 00000000..87bd9133 --- /dev/null +++ b/third_party/flutter_carplay/README.md @@ -0,0 +1,1438 @@ +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/banner.png) + +# CarPlay and Android Auto with Flutter 🚗 + +[![License: MIT](https://img.shields.io/badge/License-MIT-orange.svg)](https://opensource.org/licenses/MIT) +![Pub Version (including pre-releases)](https://img.shields.io/pub/v/flutter_carplay?include_prereleases) +![Dart Pub Likes](https://badgen.net/pub/likes/flutter_carplay) +![Dart Pub Multi-Platform](https://badgen.net/pub/flutter-platform/flutter_carplay) +![DartPub Dart SDK](https://badgen.net/pub/sdk-version/flutter_carplay) + +Flutter Apps now on Apple CarPlay and Android Auto ! `flutter_carplay` aims to make it safe to use apps made with Flutter in the car by integrating with CarPlay and Android Auto. The package takes the things you want to do while driving and puts them on the car's built-in display. + +**✨ New in v1.5.0**: Android Auto alert, grid, and tab bar templates, modal alert APIs, and richer Android Auto list loading states. + +**✨ New in v1.1.0**: CarPlay apps can now launch automatically without requiring the Flutter app to be opened first, supporting true background launch capabilities. + +> Apple announced some great features in iOS 14, one of which is users download CarPlay apps from the App Store and use them on iPhone like any other app. When an iPhone with a CarPlay app is connected to a CarPlay vehicle, the app icon appears on the CarPlay home screen. CarPlay apps are not separate apps—you add CarPlay support to an existing app. +> +> Your app uses the CarPlay framework to present UI elements to the user. iOS manages the display of UI elements and handles the interface with the car. Your app does not need to manage the layout of UI elements for different screen resolutions, or support different input hardware such as touchscreens, knobs, or touch pads. + +It supports **only iOS 14.0+**. For general design guidance, see [Human Interface Guidelines for CarPlay Apps](https://developer.apple.com/design/human-interface-guidelines/carplay/overview/introduction/). + +## 📚 Documentation + +For detailed guides and examples, check out the **[Wiki](https://github.com/oguzhnatly/flutter_carplay/wiki)**: + +- [Getting Started](https://github.com/oguzhnatly/flutter_carplay/wiki/Getting-Started) — Installation and basic setup +- [iOS Setup](https://github.com/oguzhnatly/flutter_carplay/wiki/iOS-Setup) — CarPlay entitlements and configuration +- [Android Auto Setup](https://github.com/oguzhnatly/flutter_carplay/wiki/Android-Auto-Setup) — Android Auto configuration +- [Templates](https://github.com/oguzhnatly/flutter_carplay/wiki/Templates) — All templates with code examples +- [Troubleshooting](https://github.com/oguzhnatly/flutter_carplay/wiki/Troubleshooting) — Common issues and solutions +- [FAQ](https://github.com/oguzhnatly/flutter_carplay/wiki/FAQ) — Frequently asked questions + +# Summary + +- [Overview](#overview) +- [Templates](#templates) +- [Supports](#supports) +- [What's New in latest versions](#whats-new-in-latest-versions) +- [Road Map](#road-map) +- [Contributing](#contributing) +- [Requesting the CarPlay Entitlements](#requesting-the-carplay-entitlements) +- [Disclaimer Before The Installation](#disclaimer-before-the-installation) +- [Get Started](#get-started) +- [Android Auto vs Android Automotive OS](#android-auto-vs-android-automotive-os) +- [Solve problems configuring your project](#solve-problems-configuring-your-project) +- [Usage & Features](#usage--features) +- [Templates](#templates-1) +- [LICENSE](#license) + +# Overview + +![Flutter CarPlay Introduction](https://user-images.githubusercontent.com/54781138/131184549-3cb62678-ad3f-4d67-85fb-1410bd05eaff.gif) + +Before you begin CarPlay integration, you must carefully read this section. + +[_The official App Programming Guidelines from Apple_](https://developer.apple.com/carplay/documentation/CarPlay-App-Programming-Guide.pdf) is the most valuable resource for understanding the needs, limits, and capabilities of CarPlay Apps. This documentation is a 49-page which clearly spells out the some actions required, and you are strongly advised to read it. If you are interested in a CarPlay System, [learn more about the MFi Program](https://mfi.apple.com/). + +# Templates + +## Car Play Templates + +CarPlay apps are built from a fixed set of user interface templates that iOS renders on the CarPlay screen. Each CarPlay app category can only use a restricted number of templates. Your app entitlement determines your access to templates. + +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/templates.png) + +## Android Auto Templates + +Android Auto apps built with the Android for Cars App Library are constructed using a fixed set of vehicle-optimized templates that the host renders on the car screen. Each Android Auto app category (e.g., Navigation, Point-of-Interest, IoT, etc.) can only use a restricted number of templates, and access to the Android for Cars App Library and its templates is generally restricted to supported app categories. + +https://developer.android.com/design/ui/cars/guides/templates/overview + +# Supports + +## Car Play Support + +`flutter_carplay` currently supports: + +- [x] Action Sheet Template +- [x] Alert Template +- [x] Grid Template +- [x] List Template +- [x] Tab Bar Template +- [x] Information Template (contribution from [OSch11](https://github.com/OSch11/flutter_carplay)) +- [x] Point of Interest Template (contribution from [OSch11](https://github.com/OSch11/flutter_carplay)) +- [x] Search Template +- [x] Now Playing Template (v1.1.0) + +By evaluating this information, you can request for the relevant entitlement from Apple. + +## Android Auto Support + +- [x] List Template (limited support) +- [x] Grid Template +- [x] Tab Bar Template (requires Car App API level 6+) +- [x] Alert Template +- [x] Message Template +- [x] Long Message Template +- [x] Pane Template +- [x] Now Playing Template (Automatically handled by Android Auto system) + +# What's New in latest versions + +## v1.5.0 + +- **🚘 More Android Auto Templates**: Added alert, grid, and tab bar templates, including tab selection handling +- **⚠️ Android Auto Alerts**: Added modal alert presentation and dismissal APIs for Android Auto flows +- **🧾 Better Android Auto Lists**: Added loading messages and empty view title support for list templates + +## v1.4.0 + +- **🔎 CarPlay Search Template**: Added `CPSearchTemplate` with search text, result selection, and search button callbacks +- **🤖 More Android Auto Templates**: Added `AAMessageTemplate`, `AALongMessageTemplate`, and `AAPaneTemplate`, including update APIs +- **🧾 Better Android Auto Lists**: Added stable IDs, section selection, toggles, browsable rows, and trailing images +- **🖼️ Flutter Asset SVG Support**: Flutter asset SVGs are rasterized before reaching native CarPlay and Android Auto image fields +- **📚 Android Auto Docs**: Clarified that Android Auto template rendering is different from Android Automotive OS apps + +## v1.3.0 + +- **🖼️ CPListImageRowItem**: Added support for image row list items, including element based layouts on newer iOS versions +- **🔄 Information Template Updates**: Added update methods for information items and actions without rebuilding the whole template, thanks to [@sINFdorako](https://github.com/sINFdorako) +- **🧩 Better Tab Bar Configuration**: `tabTitle`, `systemIcon`, and `showsTabBadge` are now exposed consistently on templates +- **🛠️ API Polish**: Added custom ids across models, convenient update helpers, and improved image loading reliability + +## v1.2.0 + +- **🤖 Android Auto Support**: Initial support for Android Auto with limited + features (Thanks to [@EArminjon](https://github.com/EArminjon)) + +## v1.1.0 + +- **🚀 Background Launch Support**: CarPlay apps can now start automatically without requiring the Flutter app to be opened first (Thanks to [@vanlooverenkoen](https://github.com/vanlooverenkoen) and [@EArminjon](https://github.com/EArminjon)) +- **🎵 Now Playing Template**: Navigate to the shared instance of the Now Playing Template with `FlutterCarplay.showSharedNowPlaying()` +- **🌐 Flexible Image Sources**: Load images from assets, local files (`file://`), or URLs (`https://`) (Thanks to [@vanlooverenkoen](https://github.com/vanlooverenkoen)) +- **🔧 Improved Completion Handlers**: Better reliability for list item interactions and template transitions +- **📱 Flutter 3.32.x Compatibility**: Updated for the latest Flutter versions + +Special thanks to [@EArminjon](https://github.com/EArminjon), [@vanlooverenkoen](https://github.com/vanlooverenkoen), [@snipd-mikel](https://github.com/snipd-mikel), [@APIUM](https://github.com/APIUM), and all contributors who made this release possible! + +# Road Map + +Other templates will be supported in the future releases by `flutter_carplay`. + +## Car Play Road Map + +- [ ] Map Template +- [x] Search Template +- [ ] Voice Control & "Hey Siri" for hands-free voice activation +- [ ] Contact Template + +## Android Auto Road Map +- [ ] Action Sheet Template +- [x] Information Template via Pane Template +- [ ] Point of Interest Template +- [ ] Map Template +- [ ] Search Template +- [ ] Voice Control & "Hey Google" for hands-free voice activation +- [ ] Contact Template + +# Contributing + +- Pull Requests are always welcome. +- Pull Request Reviews are even more welcome! I need help in testing. +- If you are interested in contributing more actively, please contact me at info@oguzhanatalay.com Thanks! +- If you want to help in coding, join [Discord Server](https://discord.gg/Xz6WVezFfh), so we can chat over there. + +# Requesting the CarPlay Entitlements + +> All CarPlay apps require a CarPlay app entitlement. + +If you want to build, run and publish your app on Apple with CarPlay compatibility or test or share the app with others through the TestfFlight or AdHoc, you must first request Apple to approve your Developer account for CarPlay access. The process can take from a few days to weeks or even months. It depends on the type of Entitlement you are requesting. + +To request a CarPlay app entitlement from Apple, go to https://developer.apple.com/contact/carplay and provide information about your app, including the CarPlay App Category. You must also agree to the CarPlay Entitlement Addendum. + +With this project, you can start developing and testing through Apple's CarPlay Simulator without waiting for CarPlay Entitlements. Apple will review your request. If your app meets the criteria for a CarPlay app, Apple will assign a CarPlay app entitlement to your Apple Developer Account and will notify you. + +Whether you are running the app through a simulator or developing it for distribution, you must ensure that the relevant entitlement key is added to the `Entitlements.plist` file. You must create an Entitlements.plist file if you do not already have one. + +## After you receive the CarPlay Entitlement + +After you receive the entitlement, you need to configure your Xcode project to use it, which involves several steps. You create and import a provisioning profile, and add an `Entitlements.plist` file. Your project’s code signing settings also require minor changes. + +For more detailed instructions about how to create and import the CarPlay Provisioning Profile and add an Entitlements File to Xcode Project, go to [Configure your CarPlay-enabled app with the entitlements it requires.](https://developer.apple.com/documentation/carplay/requesting_the_carplay_entitlements) + +Choose the CarPlay entitlement that matches the app category Apple approved for your project. The example app keeps `com.apple.developer.carplay-parking` so the Point of Interest demo remains available. If your app uses a different CarPlay category, replace it with the matching entitlement, for example `com.apple.developer.carplay-maps` for maps and navigation, `com.apple.developer.carplay-quick-ordering` for quick ordering, `com.apple.developer.carplay-charging` for EV charging, `com.apple.developer.carplay-fueling` for fuel stations, `com.apple.developer.carplay-driving-task` for driving tasks, `com.apple.developer.carplay-communication` for calling or messaging, or `com.apple.developer.carplay-audio` for audio playback. + +# Disclaimer Before The Installation + +You are about to make some minor changes to your Xcode project after installing this package. This is due to the fact that It requires bitcode compilation which is missing in Flutter. You will procedure that will relocate (we won't remove or edit) some Flutter and its package engines. If you're planning to add this package to a critical project for you, you should proceed cautiously. + +**Please check [THE EXAMPLE PROJECT](https://github.com/oguzhnatly/flutter_carplay/tree/master/example) before you begin to the installation.** + +THE INSTALLATION STEPS MAY BE DIFFICULT OR MAY NOT WORK PROPERLY WITH A FEW PACKAGES IN YOUR CURRENT PROJECT THAT COMMUNICATE WITH THE FLUTTER ENGINE. IF YOU ARE NOT COMPLETELY SURE WHAT YOU ARE DOING, PLEASE CREATE AN ISSUE, SO THAT I CAN HELP YOU TO SOLVE YOUR PROBLEM OR EXPLAIN WHAT YOU NEED TO. + +WHILE THE INSTALLATION PROGRESS, IF YOU TRY TO CHANGE ANYTHING (E.G. ANYTHING WORKS WITH FLUTTER ENGINE, ANYTHING IN GENERATED PLUGIN REGISTRANT SPECIFICALLY ITS LOCATION, ANY FILE NAME, ANY CLASS NAME, OR ANY OTHER FUNCTION THAT WORKS ON APPDELEGATE CLASS, TEMPLATE OR WINDOW APPLICATION DELEGATE SCENE NAMES USED IN INFO.PLIST, INCLUDED STORYBOARD NAMES, BUT NOT LIMITED TO THESE), YOU ARE MOST LIKELY TO ENCOUNTER IRREVERSIBLE ERRORS AND IT MAY DAMAGE TO YOUR PROJECT. I STRONGLY RECOMMEND THAT YOU SHOULD COPY YOUR EXISTING PROJECT BEFORE THE INSTALLATION. + +# Get Started + +## Car Play Get Started + +### Requirement Actions after Installation of the Package + +1. The iOS platform version must be set to 14.0. To make it global, navigate to `ios/Podfile` and copy the first two lines: + +```diff +# Uncomment this line to define a global platform for your project ++ platform :ios, '14.0' +- # platform :ios, '9.0' +``` + +After changing the platform version, execute the following command in your terminal to update your pod files: + +```shell +// For Apple Silicon M1 chips: +$ cd ios && arch -x86_64 pod install --repo-update + +// For Intel chips: +$ cd ios && pod install --repo-update +``` + +2. Open `ios/Runner.xcworkspace` in Xcode. In your project navigator, open `AppDelegate.swift`. + + ![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/step2.png) + + Delete the specified codes below from the application function in `AppDelegate.swift`, and change it with the code below: + +```diff +import UIKit +import Flutter + +let flutterEngine = FlutterEngine(name: "SharedEngine", project: nil, allowHeadlessExecution: true) + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { ++ flutterEngine.run() ++ GeneratedPluginRegistrant.register(with: flutterEngine) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} +``` + +3. Create a swift file named `SceneDelegate.swift` in the Runner folder (not in the xcode main project file) and add the code below: + + ```swift + @available(iOS 13.0, *) + class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = scene as? UIWindowScene else { return } + + window = UIWindow(windowScene: windowScene) + + let controller = FlutterViewController.init(engine: flutterEngine, nibName: nil, bundle: nil) + controller.loadDefaultSplashScreenView() + window?.rootViewController = controller + window?.makeKeyAndVisible() + } + } + ``` + + ![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/step3.png) + +4. One more step, open the `Info.plist` file whether in your favorite code editor or in the Xcode. I'm going to share the base code, so if you open in the Xcode, you can fill with the raw keys with the values. + + ```xml + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + CPTemplateApplicationSceneSessionRoleApplication + + + UISceneConfigurationName + CarPlay Configuration + UISceneDelegateClassName + flutter_carplay.FlutterCarPlaySceneDelegate + + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + ``` + +### That's it, you're ready to build your first CarPlay app! 🚀 😎 + +## Android Auto Get Started + +### Requirement Actions after Installation of the Package + +1. The Android platform version must be set to 21. Update your `android/app/build.gradle.kts` as below: + +```diff +# Update to use at minimum api 21 ++ minSdk = 21 +- minSdk = 19 +``` + +2. To setup Android Auto, you need to add a metadata tag in your `AndroidManifest.xml` file. Open `android/app/src/main/AndroidManifest.xml` and add the following : + + +Inside the `` tag: + +```xml + + + + + + +``` + +Inside the `` tag: + +```xml + + + + + + + + + + + + + +``` + +3. Create a new directory named `xml` inside `android/app/src/main/res/` if it doesn't already exist. Then, create a new XML file named `automotive_app_desc.xml` in the `res/xml/` directory and add the following content: + +```xml + + + + + + + +``` + +For others use, please check official [Android Auto documentation](https://developer.android.com/training/cars/apps/auto). + +4. In your `MainActivity.kt` file, make the necessary to resuse and cache the engine as follow : + +On Android Auto Service, use the same engine as the app if the app is already running, otherwise create a new one and cache using the id `FAAConstants.flutterEngineId`. +To avoid creating multiple engines, you need to override the `provideFlutterEngine` and `configureFlutterEngine` methods as below : + +```kotlin +package com.example.flutter_carplay_example + +import android.content.Context +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.FlutterEngineCache +import io.flutter.embedding.engine.dart.DartExecutor +import com.oguzhnatly.flutter_android_auto.FAAConstants + +class MainActivity : FlutterActivity() { + override fun provideFlutterEngine(context: Context): FlutterEngine? { + // Use engine from cache if it has been started by Android Auto. + return FlutterEngineCache.getInstance().get(FAAConstants.flutterEngineId); + } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + // Cache the engine to make it usable by Android Auto. + FlutterEngineCache.getInstance().put(FAAConstants.flutterEngineId, flutterEngine) + super.configureFlutterEngine(flutterEngine) + } +} +``` + +## Android Auto vs Android Automotive OS + +Android Auto and Android Automotive OS (AAOS) are different targets: + +- **Android Auto** is a projected experience. The app runs on a phone, and the vehicle display is rendered by an Android Auto host using templates from the Android for Cars App Library. This is what `flutter_carplay` supports on Android. +- **Android Automotive OS** is Android running directly in the vehicle. A Flutter app can be installed and launched on AAOS like any other Android app, but that opens the app's normal Android activity and shows the regular Flutter UI. + +`flutter_carplay` does not convert a Flutter app into a native AAOS app and does not render Android Auto templates when the app is opened normally on AAOS. The templates are rendered only by a compatible Android Auto host. + +In practice: + +- Use this package for Android Auto template apps. +- Do not expect additional behavior for a normal Flutter app installed on AAOS. +- If you are building a full native AAOS app, build and test the Flutter Android app UI directly for the vehicle environment instead of relying on Android Auto templates. + +For Android Auto testing from a phone emulator or device, use the Android Auto Desktop Head Unit instructions in the official [Android Auto testing documentation](https://developer.android.com/training/cars/testing). + +## Solve problems configuring your project + +Take a look at [this detailed issue reply](https://github.com/oguzhnatly/flutter_carplay/issues/3#issuecomment-926146126) if you got any error. + +## Usage & Features + +To see a complete example for both CarPlay and Android Auto, check the example project. + +[**See Full Example**](https://github.com/oguzhnatly/flutter_carplay/blob/master/example/lib/main.dart) + +### Basic Usage for Android Auto + +Import all the classes you need from a single file: + +```dart +import 'package:flutter_carplay/flutter_carplay.dart'; +``` + +Initialize the Android Auto controller and set a root template: + +```dart +final FlutterAndroidAuto _androidAuto = FlutterAndroidAuto(); + +await FlutterAndroidAuto.setRootTemplate( + template: AATabBarTemplate( + tabs: [ + AAListTemplate( + title: 'Home', + tabTitle: 'Home', + systemIcon: 'house.fill', + sections: [ + AAListSection( + items: [ + AAListItem( + title: 'Item 1', + subtitle: 'Detail Text', + onPress: (complete, self) { + complete(); + }, + ), + ], + ), + ], + ), + ], + ), +); +``` + +> It is recommended to set the root template in the first `initState` of your app, after Android Auto is connected. + +### Listen Connection Changes for Android Auto + +```dart +_androidAuto.addListenerOnConnectionChange(onAndroidAutoConnectionChange); + +void onAndroidAutoConnectionChange(ConnectionStatusTypes status) { + // ConnectionStatusTypes.connected + // ConnectionStatusTypes.disconnected + // ConnectionStatusTypes.unknown +} + +_androidAuto.removeListenerOnConnectionChange(); +``` + +### Android Auto API Methods + +#### **FlutterAndroidAuto.setRootTemplate** + +Sets the root template of the navigation hierarchy. If one already exists, it replaces it entirely. + +The template must be one of: **AATabBarTemplate**, **AAGridTemplate**, **AAListTemplate**, **AAPaneTemplate**, **AAMessageTemplate**, or **AALongMessageTemplate**. + +```dart +await FlutterAndroidAuto.setRootTemplate( + template: /* Android Auto template */, +); +``` + +#### **FlutterAndroidAuto.push** + +Adds a template to the navigation hierarchy and displays it. + +The template must be one of: **AAGridTemplate**, **AAListTemplate**, **AAPaneTemplate**, **AAMessageTemplate**, or **AALongMessageTemplate**. + +```dart +await FlutterAndroidAuto.push( + template: /* Android Auto template */, +); +``` + +#### **FlutterAndroidAuto.showAlert** + +Presents an `AAAlertTemplate` as a full-screen modal. Android Auto does not support true overlay modals, so the alert is pushed onto the navigation stack as a `MessageTemplate`. + +```dart +await FlutterAndroidAuto.showAlert(template: alertTemplate); +``` + +#### **FlutterAndroidAuto.popModal** + +Dismisses the currently presented `AAAlertTemplate`. + +```dart +await FlutterAndroidAuto.popModal(); +``` + +#### **FlutterAndroidAuto.updateTabBarTemplates** + +Updates the tabs of the currently displayed `AATabBarTemplate` without rebuilding the root from Dart. + +```dart +await FlutterAndroidAuto.updateTabBarTemplates(template: updatedTabBarTemplate); +``` + +#### **FlutterAndroidAuto.updatePaneTemplate** + +Updates an existing `AAPaneTemplate` and invalidates its Android Auto screen. + +```dart +await FlutterAndroidAuto.updatePaneTemplate(template: paneTemplate); +``` + +### Android Auto Message Template + +Use `AAMessageTemplate` for simple empty states, errors, or informational screens. + +```dart +final template = AAMessageTemplate( + title: 'No saved places', + message: 'Save places on your phone to access them here.', +); + +await FlutterAndroidAuto.setRootTemplate(template: template); + +await template.update( + title: 'Saved places synced', + message: 'Your saved places are now available in Android Auto.', +); +``` + +Use `AALongMessageTemplate` for longer informational text that needs more room +than a simple message template. + +```dart +final template = AALongMessageTemplate( + title: 'Safety information', + message: 'Keep your attention on the road. This longer Android Auto message ' + 'template is intended for content that needs more space.', +); + +await FlutterAndroidAuto.push(template: template); +``` + +### Android Auto Pane Template + +Use `AAPaneTemplate` for compact informational screens on Android Auto. It maps to Android's native `PaneTemplate` and is the closest Android equivalent for CarPlay-style information screens. + +```dart +await FlutterAndroidAuto.push( + template: AAPaneTemplate( + title: 'Vehicle Info', + items: [ + AAPaneItem(title: 'Battery', detail: '82%'), + AAPaneItem( + title: 'Navigation', + detail: 'Route ready', + imageUrl: 'images/svg_navigation.svg', + imageTint: const AutoImageTint.platform(), + ), + ], + actions: [ + AAPaneAction( + title: 'Refresh', + isPrimary: true, + onPress: () { + // Refresh content. + }, + ), + ], + ), +); +``` + +Pane rows are informational on Android and cannot be tapped. Use pane actions for user interaction. + +### Flutter Asset SVG Images + +Flutter asset SVGs can be used in image fields such as `CPListItem.image`, `CPGridButton.image`, `CPPointOfInterest.image`, `CPListImageRowItem` image collections, `AAListItem.imageUrl`, and `AAPaneTemplate` image fields. The package rasterizes local `.svg` assets to PNG bytes before sending them to the native CarPlay or Android Auto layer. Remote SVG URLs and `file://` SVGs are not rasterized. + +### Basic Usage for Car Play + +- Import the all classes that you need from just one file: + +```dart +import 'package:flutter_carplay/flutter_carplay.dart'; +``` + +- Initialize the CarPlay Controllers, set a root template for the CarPlay view hierarchy and ensure to well update the root template : + +```dart +final FlutterCarplay _flutterCarplay = FlutterCarplay(); + +await FlutterCarplay.setRootTemplate( + rootTemplate: CPTabBarTemplate( + templates: [ + CPListTemplate( + sections: [ + CPListSection( + items: [ + CPListItem( + text: "Item 1", + detailText: "Detail Text", + accessoryImage: 'images/logo_flutter_1080px_clr.png', + onPress: (complete, self) { + self.setDetailText("You can change the detail text.. 🚀"); + self.setAccessoryImage('images/logo_flutter_1080px_clr.png'); + Future.delayed(const Duration(seconds: 1), () { + self.setDetailText("Customizable Detail Text"); + complete(); + }); + }, + ), + ], + header: "First Section", + ), + ], + title: "Home", + showsTabBadge: false, + systemIcon: "house.fill", + ), + ], + ), + animated: true, +); +_flutterCarplay.forceUpdateRootTemplate(); +``` + +### CarPlay Search Template + +![Flutter CarPlay Search Template](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/search_template.png) + +Use `CPSearchTemplate` when your CarPlay app needs a native search screen. Your app's CarPlay entitlement controls which templates are available for its approved category. The example app keeps the parking entitlement for Point of Interest support; switch the entitlement in your app target if Apple approved a different category. Return result rows from `onUpdatedSearchText`, handle row selection in `onSelectedResult`, and call the provided completion callback after your app finishes handling the selected result. + +```dart +await FlutterCarplay.push( + template: CPSearchTemplate( + onUpdatedSearchText: (searchText, update) { + update([ + CPListItem( + text: 'Result for $searchText', + detailText: 'Tap to select', + ), + ]); + }, + onSelectedResult: (selectedItem, complete) { + complete(); + }, + ), +); +``` + +> You can set a root template without initializing the CarPlay Controllers, but some callback functions may not work or most likely you will get an error. + +> It's recommended that you should set the root template in the first initState of your app. + +### Basic Usage for Android Auto + +Android Auto row/list affordances follow the AndroidX Car App API names. Selectable lists render radio buttons on every row, `isBrowsable` renders the system navigation affordance, and `toggle` renders a switch in the row. + +```dart +final FlutterAndroidAuto flutterAndroidAuto = FlutterAndroidAuto(); + +await FlutterAndroidAuto.setRootTemplate( + template: AAListTemplate( + title: 'Home', + sections: [ + AAListSection( + selectedIndex: 0, + onSelected: (selectedIndex, selectedItem) { + print('Selected $selectedIndex: ${selectedItem.title}'); + }, + items: [ + AAListItem(title: 'Radio option 1'), + AAListItem(title: 'Radio option 2'), + ], + ), + AAListSection( + title: 'Rows', + items: [ + AAListItem( + title: 'Open details', + isBrowsable: true, + onPress: (complete, item) { + complete(); + }, + ), + AAListItem( + title: 'Toggle item', + toggle: AAToggle( + isChecked: true, + onCheckedChange: (checked, item) { + print('${item.title}: $checked'); + }, + ), + ), + ], + ), + ], + ), +); +flutterAndroidAuto.forceUpdateRootTemplate(); +``` + +### Listen Connection Changes + +You can detect connection changes, such as when CarPlay is connected to iPhone, is in the background, or is completely disconnected. + +```dart +/// Add the listener +_flutterCarplay.addListenerOnConnectionChange(onCarplayConnectionChange); + +void onCarplayConnectionChange(ConnectionStatusTypes status) { + // Do things when carplay connection status is: + // - ConnectionStatusTypes.connected + // - ConnectionStatusTypes.background + // - ConnectionStatusTypes.disconnected + // - ConnectionStatusTypes.unknown +} + +/// Remove the listener +_flutterCarplay.removeListenerOnConnectionChange(); +``` + +### CarPlay API Methods + +#### **CarPlay.setRootTemplate** + +Sets the root template of the navigation hierarchy. If a navigation +hierarchy already exists, CarPlay replaces the entire hierarchy. + +- rootTemplate is a template to use as the root of a new navigation hierarchy. If one exists, + it will replace the current rootTemplate. **Must be one of the type:** + **CPTabBarTemplate**, **CPGridTemplate**, **CPListTemplate**, **CPInformationTemplate**, **CPPointOfInterestTemplate**, or **CPSearchTemplate**. If not, it will throw a **TypeError**. +- If animated is true, CarPlay animates the presentation of the template, but will be ignored + this flag when there isn’t an existing navigation hierarchy to replace. + +> CarPlay cannot have more than 5 templates on one screen. + +```dart +FlutterCarplay.setRootTemplate( + rootTemplate: /* CPTabBarTemplate, CPGridTemplate, CPListTemplate, CPInformationTemplate, CPPointOfInterestTemplate, or CPSearchTemplate */, + animated: true, +); +// You need to call _flutterCarplay.forceUpdateRootTemplate(); after setting the root template +``` + +#### **CarPlay.push** + +Adds a template to the navigation hierarchy and displays it. + +- template is to add to the navigation hierarchy. **Must be one of the type:** **CPGridTemplate**, **CPListTemplate**, **CPInformationTemplate**, **CPPointOfInterestTemplate**, or **CPSearchTemplate**. If not, it will throw a **TypeError**. +- If animated is true, CarPlay animates the transition between templates. + +> There is a limit to the number of templates that you can push onto the screen. All apps are limited to pushing up to 5 templates in depth, including the root template. + +```dart +FlutterCarplay.push( + template: /* CPGridTemplate, CPListTemplate, CPInformationTemplate, CPPointOfInterestTemplate, or CPSearchTemplate */, + animated: true, +); +``` + +#### **CarPlay.pop** + +Removes the top-most template from the navigation hierarchy. + +- If animated is true, CarPlay animates the transition between templates. +- count represents how many times this function will occur. + +```dart +FlutterCarplay.pop(); +// OR +FlutterCarplay.pop(animated: true, count: 1); +``` + +#### **CarPlay.popToRoot** + +Removes all of the templates from the navigation hierarchy except the root template. + +- If animated is true, CarPlay animates the presentation of the template. + +```dart +FlutterCarplay.popToRoot(animated: true); +``` + +#### **CarPlay.popModal** + +Removes a modal template. Since **CPAlertTemplate** and **CPActionSheetTemplate** are both modals, they can be removed. + +- If animated is true, CarPlay animates the transition between templates. + +```dart +FlutterCarplay.popModal(animated: true); +``` + +#### **CarPlay.showSharedNowPlaying** + +Navigate to the shared instance of the Now Playing Template. This allows users to control media playback directly from CarPlay. + +- If animated is true, CarPlay animates the transition to the Now Playing template. + +```dart +FlutterCarplay.showSharedNowPlaying(animated: true); +``` + +#### **CarPlay.connectionStatus** + +Getter for current CarPlay connection status. It will return one of **ConnectionStatusTypes** as String. + +```dart +FlutterCarplay.connectionStatus +``` + +## Templates + +CarPlay supports general purpose templates such as alerts, lists, and tab bars. They are used to display contents on the CarPlay screen from the app. [The Developer Guide](https://developer.apple.com/carplay/documentation/CarPlay-App-Programming-Guide.pdf) contains more information on the templates that Apple supports. + +> If you attempt to use a template not supported by your entitlement, an exception will occur at runtime. + +### Tab Bar Template + +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/tabbar_template.png) + +The tab bar is a multi-purpose container for other templates, with each template occupying one tab in the tab bar. + +```dart +final CPTabBarTemplate tabBarTemplate = CPTabBarTemplate( + templates: [ + CPListTemplate( + sections: [ + CPListSection( + items: [ + CPListItem( + text: "Item 1", + detailText: "Detail Text", + onPress: (complete, self) { + // Returns the self class so that the item + // can be updated within self while loading + self.setDetailText("You can change the detail text.. 🚀"); + // complete function stops the loading + complete(); + }, + // Supports three image formats (v1.1.0+): + // - Asset: 'images/logo_flutter_1080px_clr.png' + // - File: 'file:///path/to/local/image.png' + // - URL: 'https://example.com/image.png' + image: 'images/logo_flutter_1080px_clr.png', + ), + CPListItem( + text: "Item 2", + detailText: "Start progress bar", + isPlaying: false, + playbackProgress: 0, + // asset name defined in pubspec.yaml + image: 'images/logo_flutter_1080px_clr.png', + onPress: (complete, self) { + complete(); + }, + ), + ], + header: "First Section", + ), + ], + title: "Home", + showsTabBadge: false, + systemIcon: "house.fill", + ), + CPListTemplate( + sections: [], + title: "Settings", + // If there is no section in the list template, + // empty view title and subtitle variants will be shown + emptyViewTitleVariants: ["Settings"], + emptyViewSubtitleVariants: [ + "No settings have been added here yet. You can start adding right away" + ], + showsTabBadge: false, + systemIcon: "gear", + ), + ], +); + +FlutterCarplay.setRootTemplate(rootTemplate: tabBarTemplate, animated: true); +``` + +### Grid Template + +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/grid_template.png) + +Grid Template is a specific style of menu that presents up to 8 items represented by an image and a title. Use the grid template to let people select from a fixed list of categories. + +```dart +final CPGridTemplate gridTemplate = CPGridTemplate( + title: "Grid Template", + buttons: [ + for (var i = 1; i < 9; i++) + CPGridButton( + titleVariants: ["Item $i"], + image: 'images/logo_flutter_1080px_clr.png', + onPress: () { + print("Grid Button $i pressed"); + }, + ), + ], +); + +FlutterCarplay.push(template: gridTemplate, animated: true); +// OR +FlutterCarplay.setRootTemplate(rootTemplate: gridTemplate, animated: true); +// You need to call _flutterCarplay.forceUpdateRootTemplate(); after setting the root template +``` + +### Alert Template + +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/alert_template.png) + +Alerts provide important information about your app's status. An alert consists of a title and one or more buttons, depending on the type. + +> If underlying conditions permit, alerts can be dismissed programatically. + +```dart +final CPAlertTemplate alertTemplate = CPAlertTemplate( + titleVariants: ["Alert Title"], + actions: [ + CPAlertAction( + title: "Okay", + style: CPAlertActionStyles.normal, + onPress: () { + print("Okay pressed"); + FlutterCarplay.popModal(animated: true); + }, + ), + CPAlertAction( + title: "Cancel", + style: CPAlertActionStyles.cancel, + onPress: () { + print("Cancel pressed"); + FlutterCarplay.popModal(animated: true); + }, + ), + CPAlertAction( + title: "Remove", + style: CPAlertActionStyles.destructive, + onPress: () { + print("Remove pressed"); + FlutterCarplay.popModal(animated: true); + }, + ), + ], +), + +FlutterCarplay.showAlert(template: alertTemplate, animated: true); +``` + +### Action Sheet Template + +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/actionsheet_template.png) + +Action Sheet Template is a type of alert that appears when control or action is taken and gives a collection of options based on the current context. + +> Use action sheets to let people initiate tasks, or to request confirmation before performing a potentially destructive operation. + +```dart +final CPActionSheetTemplate actionSheetTemplate = CPActionSheetTemplate( + title: "Action Sheet Template", + message: "This is an example message.", + actions: [ + CPAlertAction( + title: "Cancel", + style: CPAlertActionStyles.cancel, + onPress: () { + print("Cancel pressed in action sheet"); + FlutterCarplay.popModal(animated: true); + }, + ), + CPAlertAction( + title: "Dismiss", + style: CPAlertActionStyles.destructive, + onPress: () { + print("Dismiss pressed in action sheet"); + FlutterCarplay.popModal(animated: true); + }, + ), + CPAlertAction( + title: "Ok", + style: CPAlertActionStyles.normal, + onPress: () { + print("Ok pressed in action sheet"); + FlutterCarplay.popModal(animated: true); + }, + ), + ], +); + +FlutterCarplay.showActionSheet(template: actionSheetTemplate, animated: true); +``` + +### List Template + +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/list_template.png) + +A list presents data as a scrolling, single-column table of rows that can be divided into sections. Lists are ideal for text-based content, and can be used as a means of navigation for hierarchical information. Each item in a list can include attributes such as an icon, title, subtitle, disclosure indicator, progress indicator, playback status, or read status. + +> Some cars dynamically limit lists to a maximum of 12 items. You always need to be prepared to handle the case where only 12 items can be shown. Items beyond the maximum will not be shown. + +```dart +final CPListTemplate listTemplate = CPListTemplate( + sections: [ + CPListSection( + items: [ + CPListItem( + text: "Item 1", + detailText: "Detail Text", + onPress: (complete, self) { + // Returns the self class so that the item + // can be updated within self while loading + self.setDetailText("You can change the detail text.. 🚀"); + // complete function stops the loading + complete(); + }, + image: 'images/logo_flutter_1080px_clr.png', + accessoryImage: 'images/logo_flutter_1080px_clr.png', + ), + CPListItem( + text: "Item 2", + detailText: "Start progress bar", + isPlaying: false, + playbackProgress: 0, + // asset name defined in pubspec.yaml + image: 'images/logo_flutter_1080px_clr.png', + onPress: (complete, self) { + complete(); + }, + ), + ], + header: "First Section", + ), + ], + title: "Home", + showsTabBadge: false, + systemIcon: "house.fill", + // If there is no section in the list template, + // empty view title and subtitle variants will be shown + emptyViewTitleVariants: ["Home"], + emptyViewSubtitleVariants: [ + "Nothing has added here yet. You can start adding right away" + ], +); + +FlutterCarplay.push(template: listTemplate, animated: true); +// OR +FlutterCarplay.setRootTemplate(rootTemplate: listTemplate, animated: true); +// You need to call _flutterCarplay.forceUpdateRootTemplate(); after setting the root template +``` + +### Information Template + +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/information_template.png) + +An Information Template shows a list of items, and actions (max. three)) as array of text buttons. + +> The list is limited to 10 items. Items beyond the maximum will not be shown. Up to three actions are supported. + +```dart +final CPInformationTemplate informationTemplate = CPInformationTemplate( + title: "Title", + layout: CPInformationTemplateLayout.twoColumn, + actions: [ + CPTextButton( + title: "Button Title 1", + onPress: () { + print("Button 1"); + } + ), + CPTextButton( + title: "Button Title 2", + onPress: () { + print("Button 2"); + } + ), + ], + informationItems: [ + CPInformationItem(title: "Title", detail: "Detail"), + ] +); + +FlutterCarplay.push(template: informationTemplate, animated: true); +// OR +FlutterCarplay.setRootTemplate(rootTemplate: informationTemplate, animated: true); +// You need to call _flutterCarplay.forceUpdateRootTemplate(); after setting the root template +``` + +You can also update an existing `CPInformationTemplate` without rebuilding the full template. + +```dart +await _flutterCarplay.updateInformationTemplateItems( + elementId: informationTemplate.uniqueId, + items: [ + CPInformationItem(title: "Battery", detail: "85%"), + CPInformationItem(title: "Range", detail: "240 km"), + ], +); + +await _flutterCarplay.updateInformationTemplateActions( + elementId: informationTemplate.uniqueId, + actions: [ + CPTextButton( + title: "Refresh", + onPress: () { + print("Refresh tapped"); + }, + ), + ], +); +``` + +### List Image Row Item + +`CPListImageRowItem` lets you show a row of multiple images inside a `CPListTemplate` section. + +```dart +final CPListTemplate listTemplate = CPListTemplate( + title: "Gallery", + sections: [ + CPListSection( + items: [ + CPListImageRowItem( + text: "Recently played", + gridImages: [ + "https://picsum.photos/200/200?1", + "https://picsum.photos/200/200?2", + "https://picsum.photos/200/200?3", + ], + onPress: (complete, item) { + print(item.text); + complete(); + }, + onItemPress: (complete, item, index) { + print("Tapped image index: $index"); + complete(); + }, + ), + ], + ), + ], +); +``` + +Use `CPListImageRowItem.getMaximumNumberOfGridImages()` if you want to respect the host limit before building the row. + +### Point Of Interest Template + +![Flutter CarPlay](https://raw.githubusercontent.com/oguzhnatly/flutter_carplay/master/previews/point_of_interest_template.png) + +A Point Of Interest template shows multiple points of interest on a Map +The map section is determined by the points of interest. + +> The Template is limited to 12 Points of Interest. + +```dart + final CPPointOfInterestTemplate pointOfInterestTemplate = + CPPointOfInterestTemplate(title: "Title", poi: [ + CPPointOfInterest( + latitude: 51.5052, + longitude: 7.4938, + title: "Title", + subtitle: "Subtitle", + summary: "Summary", + detailTitle: "DetailTitle", + detailSubtitle: "detailSubtitle", + detailSummary: "detailSummary", + image: "images/logo_flutter_1080px_clr.png", + primaryButton: CPTextButton( + title: "Primary", + onPress: () { + print("Primary button pressed"); + } + ), + secondaryButton: CPTextButton( + title: "Secondary", + onPress: () { + print("Secondary button pressed"); + })) + ]); + + FlutterCarplay.push(template: pointOfInterestTemplate, animated: true); + // OR + FlutterCarplay.setRootTemplate(rootTemplate: pointOfInterestTemplate, animated: true); + // You need to call _flutterCarplay.forceUpdateRootTemplate(); after setting the root template +``` + +### Now Playing Template + +The Now Playing template provides a standardized interface for media playback controls in CarPlay. It uses the system's shared instance and integrates with your app's media session. + +```dart +// Navigate to the Now Playing template +FlutterCarplay.showSharedNowPlaying(animated: true); +``` + +> **Note**: The Now Playing template displays information from your app's active media session. Make sure your app is properly configured with AVAudioSession and media playback controls for the best experience. + +> **Multiple Calls Safe**: The `showSharedNowPlaying()` method can be called multiple times safely without causing issues. + +## Android Auto Templates + +Android Auto templates are built using the [Android for Cars App Library](https://developer.android.com/training/cars/apps). Each template is vehicle-optimized and rendered by the host application on the car screen. + +### Tab Bar Template (Android Auto) + +The Tab Bar Template is a container that displays multiple child templates as tabs. Rendered as `TabTemplate` from the Car App Library. + +> Requires Car App API level 6+. On older hosts, the first tab's content is shown as a plain list. Supports between 2 and 4 tabs — extra tabs beyond the limit are discarded with a warning in logcat. + +```dart +final AATabBarTemplate tabBarTemplate = AATabBarTemplate( + tabs: [ + AAListTemplate( + title: "Home", + tabTitle: "Home", + systemIcon: "house.fill", + sections: [ + AAListSection( + items: [ + AAListItem( + title: "Item 1", + subtitle: "Detail Text", + image: 'images/logo_flutter_1080px_clr.png', + onPress: (complete, self) async { + await Future.delayed(const Duration(seconds: 1)); + complete(); + }, + ), + ], + ), + ], + ), + AAGridTemplate( + title: "Grid", + tabTitle: "Grid", + systemIcon: "square.grid.2x2", + buttons: [ + AAGridButton( + titleVariants: ["Button 1"], + image: 'images/logo_flutter_1080px_clr.png', + onPress: (complete, self) async { + complete(); + }, + ), + ], + ), + ], +); + +await FlutterAndroidAuto.setRootTemplate(template: tabBarTemplate); +``` + +To update the tabs dynamically without resetting the root: + +```dart +tabBarTemplate.updateTabs([/* updated list of AATemplate */]); +await FlutterAndroidAuto.updateTabBarTemplates(template: tabBarTemplate); +``` + +### Grid Template (Android Auto) + +The Grid Template displays a grid of tappable cells, each with an image and a title. Use it to let users select from a fixed set of categories. + +> Android Auto recommends a maximum of 8 buttons per grid. + +```dart +final AAGridTemplate gridTemplate = AAGridTemplate( + title: "Grid Template", + buttons: [ + for (var i = 1; i <= 8; i++) + AAGridButton( + titleVariants: ["Item $i"], + image: 'images/logo_flutter_1080px_clr.png', + loadingMessage: "Loading...", + onPress: (complete, self) async { + await Future.delayed(const Duration(seconds: 1)); + complete(); + }, + ), + ], + emptyViewTitleVariants: ["No items available"], +); + +await FlutterAndroidAuto.push(template: gridTemplate); +// OR +await FlutterAndroidAuto.setRootTemplate(template: gridTemplate); +``` + +### Alert Template (Android Auto) + +Alerts present important information as a full-screen message with one or more action buttons. Because Android Auto does not support true overlay modals, the alert is pushed onto the navigation stack as a `MessageTemplate`. + +> Only one alert can be presented at a time. Use `FlutterAndroidAuto.popModal()` to dismiss it programmatically. + +```dart +final AAAlertTemplate alertTemplate = AAAlertTemplate( + title: "Alert Title", + message: "This is an example message.", + actions: [ + AAAlertAction( + title: "Confirm", + style: AAAlertActionStyle.normal, + onPress: () { + print("Confirm pressed"); + FlutterAndroidAuto.popModal(); + }, + ), + AAAlertAction( + title: "Cancel", + style: AAAlertActionStyle.cancel, + onPress: () { + print("Cancel pressed"); + FlutterAndroidAuto.popModal(); + }, + ), + AAAlertAction( + title: "Delete", + style: AAAlertActionStyle.destructive, + onPress: () { + print("Delete pressed"); + FlutterAndroidAuto.popModal(); + }, + ), + ], + onPresent: (bool completed) { + print("Alert presented: $completed"); + }, +); + +await FlutterAndroidAuto.showAlert(template: alertTemplate); +``` + +### List Template (Android Auto) + +A list presents data as a scrollable, single-column table divided into sections. Each item can include a title, subtitle, and an image. + +```dart +final AAListTemplate listTemplate = AAListTemplate( + title: "Home", + sections: [ + AAListSection( + title: "First Section", + items: [ + AAListItem( + title: "Item 1", + subtitle: "Detail Text", + // Supports three image formats: + // - Asset: 'images/logo.png' + // - File: 'file:///path/to/image.png' + // - URL: 'https://example.com/image.png' + image: 'images/logo_flutter_1080px_clr.png', + loadingMessage: "Loading...", + onPress: (complete, self) async { + await Future.delayed(const Duration(seconds: 1)); + complete(); + }, + ), + AAListItem( + title: "Item 2", + subtitle: "No image example", + onPress: (complete, self) async { + complete(); + }, + ), + ], + ), + ], + emptyViewTitleVariants: ["Nothing here yet"], +); + +await FlutterAndroidAuto.push(template: listTemplate); +// OR +await FlutterAndroidAuto.setRootTemplate(template: listTemplate); +``` + +# Support + +If this package has been helpful, consider supporting its development: + +[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-GitHub-ea4aaa?logo=github)](https://github.com/sponsors/oguzhnatly) + +Your support helps maintain and improve this package! ❤️ + +# Star History + +[![Star History Chart](https://star-history.dera.page/svg?repos=oguzhnatly/flutter_carplay&type=Date)](https://star-history.dera.page/#oguzhnatly/flutter_carplay&Date) + +# LICENSE + +[**MIT License**](https://github.com/oguzhnatly/flutter_carplay/blob/master/LICENSE) + +Copyright (c) 2021 Oğuzhan Atalay + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/flutter_carplay/analysis_options.yaml b/third_party/flutter_carplay/analysis_options.yaml new file mode 100644 index 00000000..e0b53705 --- /dev/null +++ b/third_party/flutter_carplay/analysis_options.yaml @@ -0,0 +1,14 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options + +formatter: + page_width: 80 +linter: + rules: + avoid_redundant_argument_values: true + prefer_final_locals: true + prefer_const_constructors: true + avoid_print: true + prefer_single_quotes: true diff --git a/third_party/flutter_carplay/android/build.gradle b/third_party/flutter_carplay/android/build.gradle new file mode 100644 index 00000000..fbdf051d --- /dev/null +++ b/third_party/flutter_carplay/android/build.gradle @@ -0,0 +1,54 @@ +group = "com.example.flutter_carplay" +version = "1.0-SNAPSHOT" + +buildscript { + ext.kotlin_version = "2.1.0" + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:8.7.3") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +android { + namespace = "com.oguzhnatly.flutter_android_auto" + + compileSdk = 35 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11 + } + + sourceSets { + main.java.srcDirs += "src/main/kotlin" + test.java.srcDirs += "src/test/kotlin" + } + + defaultConfig { + minSdk = 21 + } + + dependencies { + implementation("androidx.car.app:app:1.7.0") + testImplementation("junit:junit:4.13.2") + } +} diff --git a/third_party/flutter_carplay/android/gradle/wrapper/gradle-wrapper.properties b/third_party/flutter_carplay/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..b82aa23a --- /dev/null +++ b/third_party/flutter_carplay/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/third_party/flutter_carplay/android/settings.gradle b/third_party/flutter_carplay/android/settings.gradle new file mode 100644 index 00000000..de8e5222 --- /dev/null +++ b/third_party/flutter_carplay/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'flutter_carplay' diff --git a/third_party/flutter_carplay/android/src/main/AndroidManifest.xml b/third_party/flutter_carplay/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..3ea6bff5 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/AndroidAutoService.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/AndroidAutoService.kt new file mode 100644 index 00000000..7d8fe044 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/AndroidAutoService.kt @@ -0,0 +1,103 @@ +package com.oguzhnatly.flutter_android_auto + +import android.content.Context +import androidx.car.app.CarAppService +import androidx.car.app.CarContext +import androidx.car.app.SurfaceCallback +import androidx.car.app.validation.HostValidator +import androidx.car.app.Session +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.dart.DartExecutor +import io.flutter.embedding.engine.FlutterEngineCache; + +/// DELTA B from upstream 1.6.5. +/// +/// Upstream unconditionally builds its own bare `FlutterEngine(this)` when the +/// cache is empty. In an app whose engine owns platform channels — MeshMapper's +/// USB serial and MapLibre tile cache both live on the engine, not on an +/// Activity — that headless engine is the wrong one: its isolate has none of +/// those channels, and if the Activity later builds the real engine the app ends +/// up with two, each with its own copy of every plugin, fighting over this +/// plugin's own static template state. +/// +/// [FAAEngineProvider] lets the host app supply the engine instead. The default +/// path below is unchanged, so apps that do not set a factory behave exactly as +/// they did before. +object FAAEngineProvider { + /// Set this before the car host can start the service — an Application + /// subclass's onCreate is the only reliably early enough place, since + /// Application.onCreate always precedes any Service.onCreate in the process. + /// + /// The factory must return a fully configured engine with Dart already + /// running; this class will not call executeDartEntrypoint on it. + var factory: ((Context) -> FlutterEngine)? = null +} + +/// DELTA D from upstream 1.6.5. +/// +/// A map template draws nothing by itself: the host hands the app a Surface and +/// the app renders onto it. That rendering is entirely app-specific — MeshMapper +/// puts a MapLibre map there — so rather than teach this plugin about any one +/// map SDK, let the host app supply the SurfaceCallback. +/// +/// Leave [factory] null and nothing changes: no surface callback is registered +/// and the plugin behaves exactly as upstream. +object FAASurfaceProvider { + /// Set from the host app before a car session starts. Registering the + /// callback requires the androidx.car.app.ACCESS_SURFACE permission, which + /// is why this is opt-in rather than always-on. + var factory: ((CarContext) -> SurfaceCallback)? = null +} + +class AndroidAutoService : CarAppService() { + companion object { + /// The Android Auto session that this service is handling. + var session: AndroidAutoSession? = null + } + + override fun onCreate() { + super.onCreate() + val engineCache = FlutterEngineCache.getInstance() + val flutterEngineId = FAAConstants.flutterEngineId + + if (engineCache.get(flutterEngineId) != null) return; + + // DELTA B: give the host app first refusal on engine creation. It is + // responsible for having started Dart; we only cache the result under + // the id this plugin looks up. + FAAEngineProvider.factory?.let { create -> + engineCache.put(flutterEngineId, create(this)) + return + } + + // Create new engine in headless mode + val flutterEngine = FlutterEngine(this) + flutterEngine.dartExecutor.executeDartEntrypoint( + DartExecutor.DartEntrypoint.createDefault() + ) + // Cache the engine + engineCache.put(flutterEngineId, flutterEngine) + } + + + /// DELTA C from upstream 1.6.5: upstream returns ALLOW_ALL_HOSTS_VALIDATOR + /// unconditionally. Android's own documentation restricts that to debug + /// builds — it lets *any* app on the device bind this service and drive the + /// car surface — and shipping it is a Play review risk. Validate against the + /// car-app library's bundled allowlist of known hosts in release builds, and + /// keep the permissive behaviour only where the debug flag is set, which is + /// what the Desktop Head Unit needs. + override fun createHostValidator(): HostValidator = + if ((applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0) { + HostValidator.ALLOW_ALL_HOSTS_VALIDATOR + } else { + HostValidator.Builder(applicationContext) + .addAllowedHosts(androidx.car.app.R.array.hosts_allowlist_sample) + .build() + } + + override fun onCreateSession(): Session { + session = AndroidAutoSession() + return session!! + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAConstants.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAConstants.kt new file mode 100644 index 00000000..cfa64cef --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAConstants.kt @@ -0,0 +1,5 @@ +package com.oguzhnatly.flutter_android_auto + +object FAAConstants { + val flutterEngineId = "android_auto_id" +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAEnums.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAEnums.kt new file mode 100644 index 00000000..9c4eab65 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAEnums.kt @@ -0,0 +1,35 @@ +package com.oguzhnatly.flutter_android_auto + +enum class FAAConnectionTypes { + connected, + background, + disconnected, +} + +enum class FAAChannelTypes { + onAndroidAutoConnectionChange, + setRootTemplate, + forceUpdateRootTemplate, + pushTemplate, + popTemplate, + popToRootTemplate, + updateListTemplateSections, + updatePaneTemplate, + onListItemSelected, + onListItemSelectedComplete, + onListSectionSelected, + onToggleCheckedChange, + onPaneActionPressed, + onMapActionPressed, + onScreenBackButtonPressed, + setAlert, + closePresent, + onAlertActionPressed, + onPresentStateChanged, + updateTabBarTemplates, + onTabBarItemSelected, + onGridButtonPressed, + onGridButtonSelectedComplete, + updateMessageTemplate, + updateLongMessageTemplate, +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAHelpers.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAHelpers.kt new file mode 100644 index 00000000..dc31f1ad --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FAAHelpers.kt @@ -0,0 +1,118 @@ +package com.oguzhnatly.flutter_android_auto + +import android.content.Context +import android.graphics.BitmapFactory +import androidx.car.app.model.CarIcon +import androidx.core.graphics.drawable.IconCompat +import io.flutter.FlutterInjector +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +object FAAHelpers { + fun makeFCPChannelId(event: String): String { + return "com.oguzhnatly.flutter_android_auto" + event + } +} + +fun makeCarIconFromBytes(bytes: ByteArray?): CarIcon? { + return makeCarIconFromBytes(bytes, null) +} + +fun makeCarIconFromBytes(bytes: ByteArray?, imageTint: FAAImageTint?): CarIcon? { + if (bytes == null || bytes.isEmpty()) return null + return try { + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null + bitmap.toCarIcon(imageTint) + } catch (e: Exception) { + e.printStackTrace() + null + } +} + +suspend fun loadCarImageFromAsset( + context: Context, + assetPath: String, + imageTint: FAAImageTint? = null, +): CarIcon? { + return withContext(Dispatchers.IO) { + try { + val key = FlutterInjector.instance().flutterLoader() + .getLookupKeyForAsset(assetPath) + context.assets.open(key).use { inputStream -> + val bitmap = BitmapFactory.decodeStream(inputStream) ?: return@use null + bitmap.toCarIcon(imageTint) + } + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} + +suspend fun loadCarImageFromFile( + path: String, + imageTint: FAAImageTint? = null, +): CarIcon? { + return withContext(Dispatchers.IO) { + try { + val filePath = path.removePrefix("file://") + val file = File(filePath) + if (!file.exists()) return@withContext null + val bitmap = BitmapFactory.decodeFile(filePath) ?: return@withContext null + bitmap.toCarIcon(imageTint) + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} + +suspend fun resolveCarIcon( + context: Context, + bytes: ByteArray?, + imageUrl: String?, + imageTint: FAAImageTint? = null, +): CarIcon? { + makeCarIconFromBytes(bytes, imageTint)?.let { return it } + + val source = imageUrl?.trim() + if (source.isNullOrEmpty()) return null + + return when { + source.startsWith("http") -> loadCarImageAsync(source, imageTint) + source.startsWith("file://") -> loadCarImageFromFile(source, imageTint) + else -> loadCarImageFromAsset(context, source, imageTint) + } +} + +suspend fun loadCarImageAsync( + imageUrl: String, + imageTint: FAAImageTint? = null, +): CarIcon? { + return withContext(Dispatchers.IO) { + try { + val url = URL(imageUrl) + val connection = url.openConnection() as HttpURLConnection + connection.doInput = true + connection.connect() + val inputStream = connection.inputStream + val bitmap = BitmapFactory.decodeStream(inputStream) ?: return@withContext null + bitmap.toCarIcon(imageTint) + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} + +private fun android.graphics.Bitmap.toCarIcon(imageTint: FAAImageTint? = null): CarIcon { + val iconCompat = IconCompat.createWithBitmap(this) + val builder = CarIcon.Builder(iconCompat) + if (imageTint != null) { + builder.setTint(imageTint.toCarColor()) + } + return builder.build() +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FlutterAndroidAutoPlugin.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FlutterAndroidAutoPlugin.kt new file mode 100644 index 00000000..3c101e71 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/FlutterAndroidAutoPlugin.kt @@ -0,0 +1,1058 @@ +package com.oguzhnatly.flutter_android_auto + +import androidx.car.app.CarContext +import androidx.car.app.Screen +import androidx.car.app.ScreenManager +import androidx.car.app.model.Action +import androidx.car.app.model.CarColor +import androidx.car.app.model.CarIcon +import androidx.car.app.model.CarText +import androidx.car.app.model.GridItem +import androidx.car.app.model.GridTemplate +import androidx.car.app.model.ItemList +import androidx.car.app.model.ListTemplate +import androidx.car.app.model.LongMessageTemplate +import androidx.car.app.model.MessageTemplate +import androidx.car.app.model.Pane +import androidx.car.app.model.PaneTemplate +import androidx.car.app.model.Row +import androidx.car.app.model.SectionedItemList +import androidx.car.app.model.Tab +import androidx.car.app.model.TabContents +import androidx.car.app.model.TabTemplate +import androidx.car.app.model.Template +import androidx.car.app.model.ActionStrip +import androidx.car.app.navigation.model.MapController +import androidx.car.app.navigation.model.MapWithContentTemplate +import androidx.car.app.model.Toggle +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +class FlutterAndroidAutoPlugin : FlutterPlugin, EventChannel.StreamHandler { + private val pluginScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + lateinit var channel: MethodChannel + lateinit var eventChannel: EventChannel + + companion object { + var events: EventChannel.EventSink? = null + var currentTemplate: Template? = null + var currentScreen: Screen? = null + var currentAlertScreen: Screen? = null + + private var currentRootTemplateElementId: String? = null + private var currentTabBarData: FAATabBarTemplate? = null + private var activeTabContentId: String? = null + private var pendingTemplateElementId: String? = null + + private val templateDataByElementId = mutableMapOf>() + private val templateRuntimeTypes = mutableMapOf() + private val templateBackButtons = mutableMapOf() + private val templatesByElementId = mutableMapOf() + private val screensByElementId = mutableMapOf() + + fun sendEvent(type: String, data: Map) { + events?.success(mapOf("type" to type, "data" to data)) + } + + fun onAndroidAutoConnectionChange(status: FAAConnectionTypes) { + sendEvent( + type = FAAChannelTypes.onAndroidAutoConnectionChange.name, + data = mapOf("status" to status.name) + ) + } + } + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel( + flutterPluginBinding.binaryMessenger, + FAAHelpers.makeFCPChannelId("") + ) + eventChannel = EventChannel( + flutterPluginBinding.binaryMessenger, + FAAHelpers.makeFCPChannelId("/event") + ) + setUpHandlers() + } + + private fun setUpHandlers() { + channel.setMethodCallHandler { call, result -> + try { + when (call.method) { + FAAChannelTypes.forceUpdateRootTemplate.name -> forceUpdateRootTemplate(call, result) + FAAChannelTypes.setRootTemplate.name -> setRootTemplate(call, result) + FAAChannelTypes.pushTemplate.name -> pushTemplate(call, result) + FAAChannelTypes.popTemplate.name -> popTemplate(call, result) + FAAChannelTypes.popToRootTemplate.name -> popToRootTemplate(call, result) + FAAChannelTypes.updateListTemplateSections.name -> updateListTemplateSections(call, result) + FAAChannelTypes.updatePaneTemplate.name -> updatePaneTemplate(call, result) + FAAChannelTypes.updateMessageTemplate.name -> updateMessageTemplate(call, result) + FAAChannelTypes.updateLongMessageTemplate.name -> updateLongMessageTemplate(call, result) + FAAChannelTypes.onListItemSelectedComplete.name -> onListItemSelectedComplete(call, result) + FAAChannelTypes.onGridButtonSelectedComplete.name -> onGridButtonSelectedComplete(call, result) + FAAChannelTypes.setAlert.name -> setAlert(call, result) + FAAChannelTypes.closePresent.name -> closePresent(call, result) + FAAChannelTypes.updateTabBarTemplates.name -> updateTabBarTemplates(call, result) + else -> result.notImplemented() + } + } catch (e: Exception) { + e.printStackTrace() + result.completeWithError(e) + } + } + eventChannel.setStreamHandler(this) + } + + private fun forceUpdateRootTemplate(call: MethodCall, result: MethodChannel.Result) { + currentScreen?.invalidate() + result.success(true) + } + + private fun popTemplate(call: MethodCall, result: MethodChannel.Result) { + val carContext = AndroidAutoService.session?.carContext + if (carContext == null) { + result.error("No car context", "Android Auto is not connected", null) + return + } + + val screenManager = carContext.getCarService(ScreenManager::class.java) + if (screenManager.stackSize > 1) { + screenManager.pop() + result.success(true) + } else { + result.error("No screens to pop", "You are at root screen", null) + } + } + + private fun popToRootTemplate(call: MethodCall, result: MethodChannel.Result) { + val carContext = AndroidAutoService.session?.carContext + if (carContext == null) { + result.error("No car context", "Android Auto is not connected", null) + return + } + + val screenManager = carContext.getCarService(ScreenManager::class.java) + if (screenManager.stackSize > 1) { + screenManager.popToRoot() + result.success(true) + } else { + result.error("No screens to pop", "You are at root screen", null) + } + } + + private fun onListItemSelectedComplete(call: MethodCall, result: MethodChannel.Result) { + rebuildPendingTemplate(result) + } + + private fun onGridButtonSelectedComplete(call: MethodCall, result: MethodChannel.Result) { + rebuildPendingTemplate(result) + } + + private fun rebuildPendingTemplate(result: MethodChannel.Result) { + val elementId = pendingTemplateElementId + pendingTemplateElementId = null + if (elementId == null) { + result.success(true) + return + } + rebuildElementTemplate(elementId, result) + } + + private fun setAlert(call: MethodCall, result: MethodChannel.Result) { + val carContext = AndroidAutoService.session?.carContext + if (carContext == null) { + result.error("No car context", "Android Auto is not connected", null) + return + } + + val data = call.argument>("template") + if (data == null) { + result.error("Missing template", "template argument is required", null) + return + } + + pluginScope.launchMethodCall(result) { + val alertTemplate = FAAAlertTemplate.fromJson(data) + val messageTemplate = buildAlertMessageTemplate(alertTemplate) + + val alertScreen = object : Screen(carContext) { + override fun onGetTemplate(): Template = messageTemplate + + init { + lifecycle.addObserver(object : LifecycleEventObserver { + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (event == Lifecycle.Event.ON_DESTROY) { + currentAlertScreen = null + sendEvent( + type = FAAChannelTypes.onPresentStateChanged.name, + data = mapOf( + "elementId" to alertTemplate.elementId, + "completed" to false, + ) + ) + } + } + }) + } + } + + currentAlertScreen = alertScreen + carContext.getCarService(ScreenManager::class.java).push(alertScreen) + true + } + } + + private fun buildAlertMessageTemplate(alert: FAAAlertTemplate): Template { + val body = alert.message?.takeIf { it.isNotBlank() } ?: " " + val title = alert.title.takeIf { it.isNotBlank() } ?: " " + val builder = MessageTemplate.Builder(body).setTitle(title) + + for (action in alert.actions) { + val actionBuilder = Action.Builder() + .setTitle(action.title) + .setOnClickListener { + sendEvent( + type = FAAChannelTypes.onAlertActionPressed.name, + data = mapOf("elementId" to action.elementId) + ) + } + + if (action.style == "destructive") { + actionBuilder.setBackgroundColor(CarColor.RED) + } + + builder.addAction(actionBuilder.build()) + } + return builder.build() + } + + private fun closePresent(call: MethodCall, result: MethodChannel.Result) { + val carContext = AndroidAutoService.session?.carContext + if (carContext == null) { + result.error("No car context", "Android Auto is not connected", null) + return + } + val alertScreen = currentAlertScreen + if (alertScreen == null) { + result.error("No modal", "No modal template is currently presented", null) + return + } + carContext.getCarService(ScreenManager::class.java).pop() + currentAlertScreen = null + result.success(true) + } + + private fun updateTabBarTemplates(call: MethodCall, result: MethodChannel.Result) { + val data = call.argument>("template") + if (data == null) { + result.error("Missing template", "template argument is required", null) + return + } + val tabBarTemplate = try { + FAATabBarTemplate.fromJson(data) + } catch (e: IllegalArgumentException) { + result.error("Invalid template", e.message, null) + return + } + + pluginScope.launchMethodCall(result) { + currentTabBarData = tabBarTemplate + storeTemplateData(tabBarTemplate.elementId, "FAATabBarTemplate", data, false, currentScreen) + storeTabData(tabBarTemplate) + if (tabBarTemplate.tabs.none { it.elementId == activeTabContentId }) { + activeTabContentId = tabBarTemplate.tabs.firstOrNull()?.elementId + } + currentTemplate = buildNativeTabTemplate(tabBarTemplate) + currentScreen?.invalidate() + true + } + } + + private fun updateListTemplateSections(call: MethodCall, result: MethodChannel.Result) { + val elementId = call.argument("elementId") ?: "" + val sections = call.argument>>("sections") ?: emptyList() + val data = templateDataByElementId[elementId] + if (data == null) { + result.error("No template found", "AAListTemplate not found with elementId: $elementId", null) + return + } + val parsedSections = sections.map { FAAListSection.fromJson(it) } + val hasSelectableList = parsedSections.any { it.isSelectable } + val isSingleUntitledList = parsedSections.size == 1 && parsedSections.first().title.isEmpty() + if (hasSelectableList && !isSingleUntitledList) { + result.error( + "Invalid template", + "A selectable AAListSection must be the only section in an AAListTemplate and must not have a title.", + null, + ) + return + } + + data["sections"] = sections + rebuildElementTemplate(elementId, result) + } + + private fun updatePaneTemplate(call: MethodCall, result: MethodChannel.Result) { + val data = call.argument>("template") + if (data == null) { + result.error("Missing template", "A pane template payload is required", null) + return + } + val elementId = data["_elementId"] as? String ?: "" + if (elementId.isEmpty()) { + result.error("Missing elementId", "The pane template must have an element id", null) + return + } + + storeTemplateData(elementId, "FAAPaneTemplate", data, templateBackButtons[elementId] ?: true, screensByElementId[elementId]) + rebuildElementTemplate(elementId, result) + } + + private fun updateMessageTemplate(call: MethodCall, result: MethodChannel.Result) { + updateMessageTemplate(call, result, "message", "FAAMessageTemplate") + } + + private fun updateLongMessageTemplate(call: MethodCall, result: MethodChannel.Result) { + updateMessageTemplate(call, result, "long message", "FAALongMessageTemplate") + } + + private fun updateMessageTemplate( + call: MethodCall, + result: MethodChannel.Result, + templateType: String, + runtimeType: String, + ) { + val elementId = call.argument("elementId") ?: "" + if (elementId.isEmpty()) { + result.error("Missing elementId", "elementId is required to update a $templateType template", null) + return + } + + val data = mutableMapOf( + "_elementId" to elementId, + "title" to (call.argument("title") ?: ""), + "message" to (call.argument("message") ?: ""), + ) + storeTemplateData(elementId, runtimeType, data, templateBackButtons[elementId] ?: true, screensByElementId[elementId]) + rebuildElementTemplate(elementId, result) + } + + private fun pushTemplate(call: MethodCall, result: MethodChannel.Result) { + val carContext = AndroidAutoService.session?.carContext + if (carContext == null) { + result.error("No car context", "Android Auto is not connected", null) + return + } + + val runtimeType = call.argument("runtimeType") ?: "" + val data = call.argument>("template") + if (data == null) { + result.error("Missing template", "template argument is required", null) + return + } + val elementId = data["_elementId"] as? String ?: "" + + pluginScope.launchMethodCall(result) { + val newScreen = object : Screen(carContext) { + override fun onGetTemplate(): Template = templatesByElementId[elementId] + ?: getTemplateBlocking(runtimeType, data, true, this) + + init { + lifecycle.addObserver(object : LifecycleEventObserver { + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (event == Lifecycle.Event.ON_DESTROY) { + removeTemplateData(elementId) + sendEvent( + type = FAAChannelTypes.onScreenBackButtonPressed.name, + data = mapOf("elementId" to elementId) + ) + } + } + }) + } + } + + val template = buildTemplateForType(runtimeType, data, true, newScreen) + + storeTemplateData(elementId, runtimeType, data, true, newScreen) + templatesByElementId[elementId] = template + carContext.getCarService(ScreenManager::class.java).push(newScreen) + true + } + } + + private fun setRootTemplate(call: MethodCall, result: MethodChannel.Result) { + val runtimeType = call.argument("runtimeType") ?: "" + val data = call.argument>("template") + if (data == null) { + result.error("Missing template", "template argument is required", null) + return + } + val elementId = data["_elementId"] as? String ?: "" + + pluginScope.launchMethodCall(result) { + val template = buildTemplateForType(runtimeType, data, false, currentScreen) + + currentRootTemplateElementId = elementId + currentTemplate = template + storeTemplateData(elementId, runtimeType, data, false, currentScreen) + templatesByElementId[elementId] = template + currentScreen?.invalidate() + true + } + } + + private fun rebuildElementTemplate(elementId: String, result: MethodChannel.Result) { + val runtimeType = templateRuntimeTypes[elementId] + val data = templateDataByElementId[elementId] + if (runtimeType == null || data == null) { + result.error("No template found", "No Android Auto template found with elementId: $elementId", null) + return + } + + pluginScope.launchMethodCall(result) { + val template = if (currentTabBarData != null && currentTabBarData!!.tabs.any { it.elementId == elementId }) { + buildNativeTabTemplate(currentTabBarData!!) + } else { + buildTemplateForType( + runtimeType, + data, + templateBackButtons[elementId] ?: true, + screensByElementId[elementId], + ) + } + + if (currentTabBarData != null && currentTabBarData!!.tabs.any { it.elementId == elementId }) { + currentTemplate = template + currentScreen?.invalidate() + } else { + templatesByElementId[elementId] = template + if (currentRootTemplateElementId == elementId) { + currentTemplate = template + currentScreen?.invalidate() + } else { + screensByElementId[elementId]?.invalidate() + } + } + true + } + } + + private fun storeTemplateData( + elementId: String, + runtimeType: String, + data: Map, + addBackButton: Boolean, + screen: Screen?, + ) { + if (elementId.isEmpty()) return + templateDataByElementId[elementId] = data.toMutableMap() + templateRuntimeTypes[elementId] = runtimeType + templateBackButtons[elementId] = addBackButton + if (screen != null) screensByElementId[elementId] = screen + } + + private fun removeTemplateData(elementId: String) { + templateDataByElementId.remove(elementId) + templateRuntimeTypes.remove(elementId) + templateBackButtons.remove(elementId) + templatesByElementId.remove(elementId) + screensByElementId.remove(elementId) + } + + private fun storeTabData(tabBar: FAATabBarTemplate) { + for (tab in tabBar.tabs) { + storeTemplateData(tab.elementId, tab.runtimeType, tab.templateData, false, currentScreen) + } + } + + private fun getTemplateBlocking( + runtimeType: String, + data: Map, + addBackButton: Boolean, + owningScreen: Screen?, + ): Template = kotlinx.coroutines.runBlocking { + buildTemplateForType(runtimeType, data, addBackButton, owningScreen) + } + + private suspend fun buildTemplateForType( + runtimeType: String, + data: Map, + addBackButton: Boolean = true, + owningScreen: Screen? = null, + ): Template = when (runtimeType) { + "FAAListTemplate" -> getListTemplate(data, addBackButton, owningScreen) + "FAAGridTemplate" -> getGridTemplate(data, addBackButton, owningScreen) + "FAATabBarTemplate" -> { + val tabBarTemplate = FAATabBarTemplate.fromJson(data) + currentTabBarData = tabBarTemplate + storeTabData(tabBarTemplate) + if (activeTabContentId == null || tabBarTemplate.tabs.none { it.elementId == activeTabContentId }) { + activeTabContentId = tabBarTemplate.tabs.firstOrNull()?.elementId + } + buildNativeTabTemplate(tabBarTemplate) + } + "FAAPaneTemplate" -> getPaneTemplate(data, addBackButton) + // DELTA D: see FAASurfaceProvider in AndroidAutoService.kt. + "FAAMapWithContentTemplate" -> + getMapWithContentTemplate(data, addBackButton, owningScreen) + "FAAMessageTemplate" -> getMessageTemplate(data, addBackButton) + "FAALongMessageTemplate" -> getLongMessageTemplate(data, addBackButton) + else -> throw IllegalArgumentException("Template type $runtimeType is not supported") + } + + /// DELTA D from upstream 1.6.5: MapWithContentTemplate support. + /// + /// The host draws nothing here — the app does, onto a Surface it gets from + /// AppManager.setSurfaceCallback. All this builds is the frame: a map region + /// the app renders into, plus whatever content template Dart nested inside + /// it, which goes through the same dispatch so any supported template works + /// as the content half. + /// + /// Requires car API level 7 and the androidx.car.app.MAP_TEMPLATES and + /// androidx.car.app.ACCESS_SURFACE permissions, and an app category that is + /// permitted to draw maps (navigation, POI, or weather — not IOT). + private suspend fun getMapWithContentTemplate( + data: Map, + addBackButton: Boolean, + owningScreen: Screen?, + ): Template { + val contentType = data["contentRuntimeType"] as? String + ?: throw IllegalArgumentException("contentRuntimeType is required") + val contentData = data["contentTemplate"] as? Map + ?: throw IllegalArgumentException("contentTemplate is required") + + val content = buildTemplateForType(contentType, contentData, addBackButton, owningScreen) + val builder = MapWithContentTemplate.Builder().setContentTemplate(content) + + @Suppress("UNCHECKED_CAST") + val actions = (data["mapActions"] as? List>).orEmpty() + if (actions.isNotEmpty()) { + val strip = ActionStrip.Builder() + for (action in actions) { + strip.addAction(createMapAction(currentScreen?.carContext, action)) + } + builder.setMapController( + MapController.Builder().setMapActionStrip(strip.build()).build(), + ) + } + return builder.build() + } + + /// An action for the vertical strip the host draws down the right edge of + /// the map. + /// + /// Deliberately icon-only: ACTIONS_CONSTRAINTS_MAP leaves maxCustomTitles at + /// zero, so setting a title here gets the whole template rejected. The title + /// still travels from Dart and is used only as the content description. + private suspend fun createMapAction( + carContext: CarContext?, + action: Map, + ): Action { + val elementId = action["_elementId"] as? String ?: "" + val imageUrl = action["imageUrl"] as? String + val builder = Action.Builder() + + val icon = if (carContext != null && imageUrl != null) { + resolveCarIcon(carContext, null, imageUrl, null) + } else null + if (icon != null) builder.setIcon(icon) + + if (action["isPrimary"] == true) builder.setFlags(Action.FLAG_PRIMARY) + if (action["onPress"] == true) { + builder.setOnClickListener { + sendEvent( + type = FAAChannelTypes.onMapActionPressed.name, + data = mapOf("elementId" to elementId), + ) + } + } + return builder.build() + } + + private suspend fun buildNativeTabTemplate(tabBar: FAATabBarTemplate): Template { + val activeId = activeTabContentId ?: tabBar.tabs.firstOrNull()?.elementId + val activeTab = tabBar.tabs.find { it.elementId == activeId } + ?: tabBar.tabs.firstOrNull() + ?: return ListTemplate.Builder().setLoading(true).build() + + val carContext = AndroidAutoService.session?.carContext + val supportsTabTemplate = carContext != null && carContext.getCarAppApiLevel() >= 6 + + if (tabBar.tabs.size < 2 || !supportsTabTemplate) { + return buildInnerTemplateForTab(activeTab, false) + } + + val cappedTabs = tabBar.tabs.take(4) + val resolvedActiveTab = if (cappedTabs.contains(activeTab)) activeTab else cappedTabs.first() + + val tabCallback = object : TabTemplate.TabCallback { + override fun onTabSelected(tabContentId: String) { + pluginScope.launch { + activeTabContentId = tabContentId + currentTabBarData?.let { + currentTemplate = buildNativeTabTemplate(it) + currentScreen?.invalidate() + } + sendEvent( + type = FAAChannelTypes.onTabBarItemSelected.name, + data = mapOf("elementId" to tabContentId) + ) + } + } + } + + val builder = TabTemplate.Builder(tabCallback) + builder.setHeaderAction(Action.APP_ICON) + builder.setActiveTabContentId(resolvedActiveTab.elementId) + + for (tab in cappedTabs) { + builder.addTab( + Tab.Builder() + .setTitle(resolveTabTitle(tab)) + .setIcon(resolveTabIcon(carContext, tab)) + .setContentId(tab.elementId) + .build() + ) + } + + val innerTemplate = buildInnerTemplateForTab(resolvedActiveTab, false) + builder.setTabContents(TabContents.Builder(innerTemplate).build()) + return builder.build() + } + + private suspend fun buildInnerTemplateForTab( + tab: FAATabBarItem, + addBackButton: Boolean, + ): Template { + val data = templateDataByElementId[tab.elementId] ?: tab.templateData + val runtimeType = templateRuntimeTypes[tab.elementId] ?: tab.runtimeType + return buildTemplateForType(runtimeType, data, addBackButton, currentScreen) + } + + private fun resolveTabTitle(tab: FAATabBarItem): String { + val data = templateDataByElementId[tab.elementId] ?: tab.templateData + return data["tabTitle"] as? String ?: data["title"] as? String ?: tab.tabTitle + } + + private suspend fun resolveTabIcon(carContext: CarContext?, tab: FAATabBarItem): CarIcon { + val data = templateDataByElementId[tab.elementId] ?: tab.templateData + val iconUrl = data["iconUrl"] as? String ?: tab.iconUrl + if (carContext != null && !iconUrl.isNullOrBlank()) { + resolveCarIcon(carContext, null, iconUrl)?.let { return it } + } + + val systemIcon = data["systemIcon"] as? String ?: tab.systemIcon + if (carContext != null && !systemIcon.isNullOrBlank()) { + val value = systemIcon.trim() + if (value.startsWith("http") || value.startsWith("file://") || value.contains("/") || value.contains(".")) { + resolveCarIcon(carContext, null, value)?.let { return it } + } + } + + return when (systemIcon?.lowercase()) { + "map", "map.fill", "location", "location.fill", "navigation", "navigation.fill", + "location.north", "location.north.fill" -> CarIcon.PAN + "exclamationmark", "exclamationmark.triangle", "exclamationmark.triangle.fill", + "alert", "bell", "bell.fill" -> CarIcon.ALERT + "pencil", "pencil.circle", "compose", "square.and.pencil", "message", + "message.fill", "bubble.left", "bubble.right" -> CarIcon.COMPOSE_MESSAGE + "chevron.backward", "chevron.left", "arrow.backward", "back", "arrow.left", + "arrowshape.backward", "arrowshape.backward.fill" -> CarIcon.BACK + "xmark.circle", "xmark", "multiply", "error", "exclamationmark.circle" -> CarIcon.ERROR + "hand.draw", "hand.point.up", "pan" -> CarIcon.PAN + else -> CarIcon.COMPOSE_MESSAGE + } + } + + private fun getMessageTemplate( + data: Map, + addBackButton: Boolean = true, + ): Template = buildMessageTemplate( + data, + addBackButton, + createBuilder = { MessageTemplate.Builder(it) }, + setTitle = { title -> setTitle(title) }, + setHeaderAction = { action -> setHeaderAction(action) }, + build = { build() }, + ) + + private fun getLongMessageTemplate( + data: Map, + addBackButton: Boolean = true, + ): Template = buildMessageTemplate( + data, + addBackButton, + createBuilder = { LongMessageTemplate.Builder(it) }, + setTitle = { title -> setTitle(title) }, + setHeaderAction = { action -> setHeaderAction(action) }, + build = { build() }, + ) + + private fun buildMessageTemplate( + data: Map, + addBackButton: Boolean, + createBuilder: (String) -> Builder, + setTitle: Builder.(String) -> Unit, + setHeaderAction: Builder.(Action) -> Unit, + build: Builder.() -> Template, + ): Template { + val template = FAAMessageTemplate.fromJson(data) + val builder = createBuilder(template.message) + builder.setTitle(template.title) + if (addBackButton) builder.setHeaderAction(Action.BACK) + return builder.build() + } + + private suspend fun getPaneTemplate( + data: Map, + addBackButton: Boolean = true, + ): Template { + val carContext = AndroidAutoService.session?.carContext + val template = FAAPaneTemplate.fromJson(data) + val paneBuilder = Pane.Builder() + + val isLoading = template.isLoading || template.items.isEmpty() + paneBuilder.setLoading(isLoading) + if (!isLoading) { + for (item in template.items) { + paneBuilder.addRow(createPaneRowFromItem(carContext, item)) + } + + val imageIcon = makeCarIconFromBytes(template.imageData, template.imageTint) + ?: if (carContext != null && template.imageUrl != null) { + resolveCarIcon(carContext, null, template.imageUrl, template.imageTint) + } else null + if (imageIcon != null) paneBuilder.setImage(imageIcon) + + for (action in template.actions) { + paneBuilder.addAction(createPaneAction(carContext, action)) + } + } + + // DELTA D: an empty title means no header. build() does not require one, + // and setting an empty CarText would draw an empty header bar rather + // than none at all. + val paneTemplateBuilder = PaneTemplate.Builder(paneBuilder.build()) + if (template.title.isNotEmpty()) paneTemplateBuilder.setTitle(template.title) + if (addBackButton) paneTemplateBuilder.setHeaderAction(Action.BACK) + return paneTemplateBuilder.build() + } + + private suspend fun createPaneRowFromItem( + carContext: CarContext?, + item: FAAPaneItem, + ): Row { + val rowBuilder = Row.Builder().setTitle(CarText.create(item.title)) + item.detail?.let { rowBuilder.addText(CarText.create(it)) } + + val imageIcon = makeCarIconFromBytes(item.imageData, item.imageTint) + ?: if (carContext != null && item.imageUrl != null) { + resolveCarIcon(carContext, null, item.imageUrl, item.imageTint) + } else null + if (imageIcon != null) { + rowBuilder.setImage( + imageIcon, + if (item.imageTint != null) Row.IMAGE_TYPE_ICON else Row.IMAGE_TYPE_SMALL, + ) + } + return rowBuilder.build() + } + + private suspend fun createPaneAction( + carContext: CarContext?, + action: FAAPaneAction, + ): Action { + val actionBuilder = Action.Builder().setTitle(action.title) + val imageIcon = makeCarIconFromBytes(action.imageData, action.imageTint) + ?: if (carContext != null && action.imageUrl != null) { + resolveCarIcon(carContext, null, action.imageUrl, action.imageTint) + } else null + if (imageIcon != null) actionBuilder.setIcon(imageIcon) + if (action.isPrimary) actionBuilder.setFlags(Action.FLAG_PRIMARY) + if (action.isOnPressListenerActive) { + actionBuilder.setOnClickListener { + sendEvent( + type = FAAChannelTypes.onPaneActionPressed.name, + data = mapOf("elementId" to action.elementId), + ) + } + } + return actionBuilder.build() + } + + private suspend fun getListTemplate( + data: Map, + addBackButton: Boolean = true, + owningScreen: Screen? = null, + ): Template { + val carContext = AndroidAutoService.session?.carContext + val template = FAAListTemplate.fromJson(data) + val builder = ListTemplate.Builder().setTitle(template.title) + val emptyMessage = template.emptyViewTitleVariants.firstOrNull() + val isEmpty = template.sections.isEmpty() || template.sections.all { it.items.isEmpty() } + + if (isEmpty) { + if (emptyMessage != null) { + builder.setLoading(false) + builder.setSingleList(ItemList.Builder().setNoItemsMessage(emptyMessage).build()) + } else { + builder.setLoading(true) + } + } else { + builder.setLoading(false) + val isSingleList = template.sections.size == 1 && template.sections.first().title.isEmpty() + if (isSingleList) { + builder.setSingleList( + createItemListFromSection(carContext, template.sections.first(), template.elementId, "FAAListTemplate", owningScreen) + ) + } else { + for (section in template.sections) { + builder.addSectionedList( + SectionedItemList.create( + createItemListFromSection(carContext, section, template.elementId, "FAAListTemplate", owningScreen), + section.title, + ) + ) + } + } + } + + if (addBackButton) builder.setHeaderAction(Action.BACK) + return builder.build() + } + + private suspend fun createItemListFromSection( + carContext: CarContext?, + section: FAAListSection, + templateElementId: String, + runtimeType: String, + owningScreen: Screen?, + ): ItemList { + val itemListBuilder = ItemList.Builder() + val useSelectionListener = section.isOnSelectedListenerActive || section.selectedIndex != null + + for (item in section.items) { + itemListBuilder.addItem( + createRowFromItem( + carContext, + item, + templateElementId, + runtimeType, + owningScreen, + enableOnClick = !useSelectionListener, + ) + ) + } + + if (useSelectionListener) { + itemListBuilder.setOnSelectedListener { selectedIndex -> + if (section.isOnSelectedListenerActive) { + sendEvent( + type = FAAChannelTypes.onListSectionSelected.name, + data = mapOf( + "elementId" to section.elementId, + "selectedIndex" to selectedIndex, + ) + ) + } + } + } + + section.selectedIndex?.let { selectedIndex -> + if (selectedIndex >= 0 && selectedIndex < section.items.size) { + itemListBuilder.setSelectedIndex(selectedIndex) + } + } + + return itemListBuilder.build() + } + + private suspend fun createRowFromItem( + carContext: CarContext?, + item: FAAListItem, + templateElementId: String, + runtimeType: String, + owningScreen: Screen?, + enableOnClick: Boolean = true, + ): Row { + val rowBuilder = Row.Builder().setTitle(CarText.create(item.title)) + item.subtitle?.let { rowBuilder.addText(CarText.create(it)) } + + val imageIcon = makeCarIconFromBytes(item.imageData, item.imageTint) + ?: if (carContext != null && item.imageUrl != null) { + resolveCarIcon(carContext, null, item.imageUrl, item.imageTint) + } else null + if (imageIcon != null) { + rowBuilder.setImage( + imageIcon, + if (item.imageTint != null) Row.IMAGE_TYPE_ICON else Row.IMAGE_TYPE_SMALL, + ) + } + + val trailingIcon = makeCarIconFromBytes(item.trailingImageData, item.trailingImageTint) + ?: if (carContext != null && item.trailingImage != null) { + resolveCarIcon(carContext, null, item.trailingImage, item.trailingImageTint) + } else null + if (trailingIcon != null) { + rowBuilder.addAction(Action.Builder().setIcon(trailingIcon).build()) + } + + item.isBrowsable?.let { rowBuilder.setBrowsable(it) } + + item.toggle?.let { toggle -> + val toggleBuilder = Toggle.Builder { checked -> + if (toggle.isOnCheckedChangeListenerActive) { + sendEvent( + type = FAAChannelTypes.onToggleCheckedChange.name, + data = mapOf( + "elementId" to item.elementId, + "checked" to checked, + ) + ) + } + }.setChecked(toggle.isChecked) + toggle.isEnabled?.let { toggleBuilder.setEnabled(it) } + rowBuilder.setToggle(toggleBuilder.build()) + } + + if (enableOnClick && item.isOnPressListenerActive) { + rowBuilder.setOnClickListener { + showLoadingForTemplate(templateElementId, runtimeType, item.loadingMessage) + sendEvent( + type = FAAChannelTypes.onListItemSelected.name, + data = mapOf("elementId" to item.elementId) + ) + } + } + return rowBuilder.build() + } + + private suspend fun getGridTemplate( + data: Map, + addBackButton: Boolean = true, + owningScreen: Screen? = null, + ): Template { + val carContext = AndroidAutoService.session?.carContext + val template = FAAGridTemplate.fromJson(data) + val builder = GridTemplate.Builder().setTitle(template.title) + val emptyMessage = template.emptyViewTitleVariants.firstOrNull() + + if (template.buttons.isEmpty()) { + if (emptyMessage != null) { + builder.setLoading(false) + builder.setSingleList(ItemList.Builder().setNoItemsMessage(emptyMessage).build()) + } else { + builder.setLoading(true) + } + } else { + builder.setLoading(false) + val itemListBuilder = ItemList.Builder() + for (button in template.buttons) { + itemListBuilder.addItem( + createGridItemFromButton(carContext, button, template.elementId, "FAAGridTemplate", owningScreen) + ) + } + builder.setSingleList(itemListBuilder.build()) + } + + if (addBackButton) builder.setHeaderAction(Action.BACK) + return builder.build() + } + + private suspend fun createGridItemFromButton( + carContext: CarContext?, + button: FAAGridButton, + templateElementId: String, + runtimeType: String, + owningScreen: Screen?, + ): GridItem { + val itemBuilder = GridItem.Builder().setTitle(button.title) + val carIcon = makeCarIconFromBytes(button.imageData) + ?: if (carContext != null && button.image != null) { + resolveCarIcon(carContext, null, button.image) + } else null + + itemBuilder.setImage(carIcon ?: CarIcon.COMPOSE_MESSAGE) + + if (button.isOnPressListenerActive) { + itemBuilder.setOnClickListener { + showLoadingForTemplate(templateElementId, runtimeType, button.loadingMessage) + sendEvent( + type = FAAChannelTypes.onGridButtonPressed.name, + data = mapOf("elementId" to button.elementId) + ) + } + } + return itemBuilder.build() + } + + private fun showLoadingForTemplate( + templateElementId: String, + runtimeType: String, + loadingMessage: String? = null, + ) { + pendingTemplateElementId = templateElementId + val loading = buildLoadingTemplate(runtimeType, loadingMessage, templateBackButtons[templateElementId] ?: false) + + if (currentTabBarData != null && currentTabBarData!!.tabs.any { it.elementId == templateElementId }) { + currentTemplate = loading + currentScreen?.invalidate() + return + } + + templatesByElementId[templateElementId] = loading + if (currentRootTemplateElementId == templateElementId) { + currentTemplate = loading + currentScreen?.invalidate() + } else { + screensByElementId[templateElementId]?.invalidate() + } + } + + private fun buildLoadingTemplate( + runtimeType: String, + loadingMessage: String?, + addBackButton: Boolean, + ): Template { + return if (runtimeType == "FAAGridTemplate") { + GridTemplate.Builder() + .setLoading(true) + .apply { + if (!loadingMessage.isNullOrBlank()) setTitle(loadingMessage) + if (addBackButton) setHeaderAction(Action.BACK) + } + .build() + } else { + ListTemplate.Builder() + .setLoading(true) + .apply { + if (!loadingMessage.isNullOrBlank()) setTitle(loadingMessage) + if (addBackButton) setHeaderAction(Action.BACK) + } + .build() + } + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + FlutterAndroidAutoPlugin.events = events + } + + override fun onCancel(arguments: Any?) { + events?.endOfStream() + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + eventChannel.setStreamHandler(null) + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/MethodCallResult.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/MethodCallResult.kt new file mode 100644 index 00000000..8c6e6da6 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/MethodCallResult.kt @@ -0,0 +1,35 @@ +package com.oguzhnatly.flutter_android_auto + +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +private const val METHOD_CALL_ERROR_CODE = "android_auto_error" +private const val METHOD_CALL_CANCELLED_CODE = "operation_cancelled" + +internal fun CoroutineScope.launchMethodCall( + result: MethodChannel.Result, + block: suspend () -> T, +): Job = launch { + val value = try { + block() + } catch (exception: CancellationException) { + result.error(METHOD_CALL_CANCELLED_CODE, exception.message, null) + throw exception + } catch (exception: Exception) { + result.completeWithError(exception) + return@launch + } + + result.success(value) +} + +internal fun MethodChannel.Result.completeWithError(exception: Exception) { + error( + METHOD_CALL_ERROR_CODE, + exception.message ?: exception.javaClass.simpleName, + exception.stackTraceToString(), + ) +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/Screen.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/Screen.kt new file mode 100644 index 00000000..f2058b6c --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/Screen.kt @@ -0,0 +1,36 @@ +package com.oguzhnatly.flutter_android_auto + + +import android.Manifest +import android.content.Intent +import android.net.Uri +import android.service.credentials.Action +import android.text.Spannable +import android.text.SpannableString +import androidx.annotation.OptIn +import androidx.car.app.CarAppService +import androidx.car.app.CarContext +import androidx.car.app.Screen +import androidx.car.app.Session +import androidx.car.app.annotations.ExperimentalCarApi +import androidx.car.app.model.CarLocation +import androidx.car.app.model.ItemList +import androidx.car.app.model.Metadata +import androidx.car.app.model.PlaceListMapTemplate +import androidx.car.app.model.PlaceMarker +import androidx.car.app.model.Row +import androidx.car.app.model.Template +import androidx.car.app.model.ListTemplate + +class MainScreen(carContext: CarContext) : Screen(carContext) { + init {} + + override fun onGetTemplate(): Template { + val appName = + carContext.applicationInfo.loadLabel(carContext.packageManager) + .toString() ?: "" + + return FlutterAndroidAutoPlugin.currentTemplate + ?: ListTemplate.Builder().setTitle(appName).setLoading(true).build() + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/Session.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/Session.kt new file mode 100644 index 00000000..f73a91d0 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/Session.kt @@ -0,0 +1,49 @@ +package com.oguzhnatly.flutter_android_auto + +import android.content.Intent +import androidx.car.app.AppManager +import androidx.car.app.Screen +import androidx.car.app.Session +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner + +class AndroidAutoSession : Session() { + override fun onCreateScreen(intent: Intent): Screen { + val screen = MainScreen(carContext) + FlutterAndroidAutoPlugin.currentScreen = screen + + // DELTA D: hand the host app the CarContext so it can render onto the + // car's Surface. Opt-in — apps that set no factory never touch + // AppManager, and so never need the ACCESS_SURFACE permission. + FAASurfaceProvider.factory?.let { create -> + carContext.getCarService(AppManager::class.java) + .setSurfaceCallback(create(carContext)) + } + + lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + FlutterAndroidAutoPlugin.onAndroidAutoConnectionChange( + FAAConnectionTypes.connected + ) + super.onStart(owner) + } + + + override fun onResume(owner: LifecycleOwner) { + FlutterAndroidAutoPlugin.onAndroidAutoConnectionChange( + FAAConnectionTypes.connected + ) + super.onResume(owner) + } + + override fun onStop(owner: LifecycleOwner) { + FlutterAndroidAutoPlugin.onAndroidAutoConnectionChange( + FAAConnectionTypes.disconnected + ) + super.onStop(owner) + } + }) + + return screen + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/alert/FAAAlertAction.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/alert/FAAAlertAction.kt new file mode 100644 index 00000000..5a3167cf --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/alert/FAAAlertAction.kt @@ -0,0 +1,16 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAAlertAction( + val elementId: String, + val title: String, + val style: String, +) { + companion object { + fun fromJson(map: Map): FAAAlertAction { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val style = map["style"] as? String ?: "normal" + return FAAAlertAction(elementId, title, style) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/alert/FAAAlertTemplate.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/alert/FAAAlertTemplate.kt new file mode 100644 index 00000000..7f713b03 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/alert/FAAAlertTemplate.kt @@ -0,0 +1,21 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAAlertTemplate( + val elementId: String, + val title: String, + val message: String?, + val actions: List, +) { + companion object { + fun fromJson(map: Map): FAAAlertTemplate { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val message = map["message"] as? String + val actions = (map["actions"] as? List<*>)?.mapNotNull { + (it as? Map<*, *>)?.mapKeys { entry -> entry.key.toString() } + ?.let { FAAAlertAction.fromJson(it) } + } ?: emptyList() + return FAAAlertTemplate(elementId, title, message, actions) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/common/FAAImageTint.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/common/FAAImageTint.kt new file mode 100644 index 00000000..986a6df9 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/common/FAAImageTint.kt @@ -0,0 +1,78 @@ +package com.oguzhnatly.flutter_android_auto + +import android.graphics.Color +import androidx.car.app.model.CarColor +import kotlin.math.roundToInt + +data class FAAImageTint( + val type: String, + val color: FAAColor? = null, + val darkColor: FAAColor? = null, + val selectedSafe: Boolean = true, +) { + fun toCarColor(): CarColor { + return when (type) { + "platform" -> CarColor.DEFAULT + "primary" -> CarColor.PRIMARY + "secondary" -> CarColor.SECONDARY + "red" -> CarColor.RED + "green" -> CarColor.GREEN + "blue" -> CarColor.BLUE + "yellow" -> CarColor.YELLOW + "custom" -> { + val lightColor = color?.toColorInt() ?: Color.WHITE + val darkColor = darkColor?.toColorInt() ?: lightColor + CarColor.createCustom(lightColor, darkColor) + } + else -> CarColor.DEFAULT + } + } + + companion object { + fun fromJson(map: Map?): FAAImageTint? { + if (map == null) return null + val type = map["type"] as? String ?: return null + return FAAImageTint( + type = type, + color = FAAColor.fromJson(map["color"] as? Map), + darkColor = FAAColor.fromJson(map["darkColor"] as? Map), + selectedSafe = map["selectedSafe"] as? Boolean ?: true, + ) + } + } +} + +data class FAAColor( + val red: Int, + val green: Int, + val blue: Int, + val alpha: Double = 1.0, +) { + fun toColorInt(): Int { + val alphaInt = if (alpha > 1) alpha.roundToInt() else (alpha * 255).roundToInt() + return Color.argb( + alphaInt.coerceIn(0, 255), + red.coerceIn(0, 255), + green.coerceIn(0, 255), + blue.coerceIn(0, 255), + ) + } + + companion object { + fun fromJson(map: Map?): FAAColor? { + if (map == null) return null + val red = numberValue(map["red"])?.roundToInt() ?: return null + val green = numberValue(map["green"])?.roundToInt() ?: return null + val blue = numberValue(map["blue"])?.roundToInt() ?: return null + val alpha = numberValue(map["alpha"]) ?: 1.0 + return FAAColor(red, green, blue, alpha) + } + + private fun numberValue(value: Any?): Double? { + return when (value) { + is Number -> value.toDouble() + else -> null + } + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/grid/FAAGridButton.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/grid/FAAGridButton.kt new file mode 100644 index 00000000..c9996a84 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/grid/FAAGridButton.kt @@ -0,0 +1,33 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAGridButton( + val elementId: String, + val titleVariants: List, + val image: String?, + val imageData: ByteArray?, + val loadingMessage: String?, + val isOnPressListenerActive: Boolean, +) { + val title: String get() = titleVariants.firstOrNull() ?: "" + + companion object { + fun fromJson(map: Map): FAAGridButton { + val elementId = map["_elementId"] as? String ?: "" + val titleVariants = (map["titleVariants"] as? List<*>) + ?.filterIsInstance() ?: emptyList() + val image = map["image"] as? String + val imageData = map["imageData"] as? ByteArray + val loadingMessage = map["loadingMessage"] as? String + val isOnPressListenerActive = map["onPress"] as? Boolean ?: false + + return FAAGridButton( + elementId, + titleVariants, + image, + imageData, + loadingMessage, + isOnPressListenerActive, + ) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/grid/FAAGridTemplate.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/grid/FAAGridTemplate.kt new file mode 100644 index 00000000..6f6f3926 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/grid/FAAGridTemplate.kt @@ -0,0 +1,24 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAGridTemplate( + val elementId: String, + val title: String, + val buttons: List, + val emptyViewTitleVariants: List, +) { + companion object { + fun fromJson(map: Map): FAAGridTemplate { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val buttons = (map["buttons"] as? List<*>)?.mapNotNull { + (it as? Map<*, *>) + ?.mapKeys { entry -> entry.key.toString() } + ?.let { btn -> FAAGridButton.fromJson(btn) } + } ?: emptyList() + val emptyViewTitleVariants = (map["emptyViewTitleVariants"] as? List<*>) + ?.filterIsInstance() ?: emptyList() + + return FAAGridTemplate(elementId, title, buttons, emptyViewTitleVariants) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListItem.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListItem.kt new file mode 100644 index 00000000..4ac2fd10 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListItem.kt @@ -0,0 +1,73 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAListItem( + val elementId: String, + val title: String, + val subtitle: String? = null, + val imageUrl: String? = null, + val imageData: ByteArray? = null, + val imageTint: FAAImageTint? = null, + val trailingImage: String? = null, + val trailingImageData: ByteArray? = null, + val trailingImageTint: FAAImageTint? = null, + val loadingMessage: String? = null, + val isBrowsable: Boolean? = null, + val toggle: FAAToggle? = null, + val isOnPressListenerActive: Boolean, +) { + companion object { + fun fromJson(map: Map): FAAListItem { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val subtitle = map["subtitle"] as? String + val imageUrl = map["imageUrl"] as? String ?: map["image"] as? String + val imageData = map["imageData"] as? ByteArray + val imageTint = FAAImageTint.fromJson(map["imageTint"] as? Map) + val trailingImage = map["trailingImage"] as? String + val trailingImageData = map["trailingImageData"] as? ByteArray + val trailingImageTint = + FAAImageTint.fromJson(map["trailingImageTint"] as? Map) + val loadingMessage = map["loadingMessage"] as? String + val isBrowsable = map["isBrowsable"] as? Boolean + val toggle = (map["toggle"] as? Map<*, *>)?.mapKeys { entry -> + entry.key.toString() + }?.let { FAAToggle.fromJson(it) } + val isOnPressListenerActive = map["onPress"] as? Boolean ?: false + + return FAAListItem( + elementId, + title, + subtitle, + imageUrl, + imageData, + imageTint, + trailingImage, + trailingImageData, + trailingImageTint, + loadingMessage, + isBrowsable, + toggle, + isOnPressListenerActive, + ) + } + } +} + +data class FAAToggle( + val isChecked: Boolean = false, + val isEnabled: Boolean? = null, + val isOnCheckedChangeListenerActive: Boolean = false, +) { + companion object { + fun fromJson(map: Map): FAAToggle { + val isChecked = map["isChecked"] as? Boolean ?: false + val isEnabled = map["isEnabled"] as? Boolean + val isOnCheckedChangeListenerActive = + map["onCheckedChange"] as? Boolean ?: false + + return FAAToggle( + isChecked, isEnabled, isOnCheckedChangeListenerActive + ) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListSection.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListSection.kt new file mode 100644 index 00000000..ff3bedc7 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListSection.kt @@ -0,0 +1,30 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAListSection( + val elementId: String, + val title: String, + val items: List, + val selectedIndex: Int? = null, + val isOnSelectedListenerActive: Boolean = false, +) { + val isSelectable: Boolean + get() = selectedIndex != null || isOnSelectedListenerActive + + companion object { + fun fromJson(map: Map): FAAListSection { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val items = (map["items"] as? List<*>)?.mapNotNull { + (it as? Map<*, *>)?.mapKeys { entry -> entry.key.toString() } + ?.let { FAAListItem.fromJson(it) } + } ?: emptyList() + val selectedIndex = map["selectedIndex"] as? Int + val isOnSelectedListenerActive = + map["onSelected"] as? Boolean ?: false + + return FAAListSection( + elementId, title, items, selectedIndex, isOnSelectedListenerActive + ) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListTemplate.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListTemplate.kt new file mode 100644 index 00000000..8b7b3dde --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/list/FAAListTemplate.kt @@ -0,0 +1,31 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAListTemplate( + val elementId: String, + val title: String, + val sections: List, + val emptyViewTitleVariants: List, +) { + init { + val hasSelectableList = sections.any { it.isSelectable } + val isSingleUntitledList = sections.size == 1 && sections.first().title.isEmpty() + require(!hasSelectableList || isSingleUntitledList) { + "A selectable AAListSection must be the only section in an AAListTemplate and must not have a title." + } + } + + companion object { + fun fromJson(map: Map): FAAListTemplate { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val sections = (map["sections"] as? List<*>)?.mapNotNull { + (it as? Map<*, *>)?.mapKeys { entry -> entry.key.toString() } + ?.let { FAAListSection.fromJson(it) } + } ?: emptyList() + val emptyViewTitleVariants = (map["emptyViewTitleVariants"] as? List<*>) + ?.filterIsInstance() ?: emptyList() + + return FAAListTemplate(elementId, title, sections, emptyViewTitleVariants) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/message/FAAMessageTemplate.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/message/FAAMessageTemplate.kt new file mode 100644 index 00000000..00aec88f --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/message/FAAMessageTemplate.kt @@ -0,0 +1,17 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAMessageTemplate( + val elementId: String, + val title: String, + val message: String, +) { + companion object { + fun fromJson(map: Map): FAAMessageTemplate { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val message = map["message"] as? String ?: "" + + return FAAMessageTemplate(elementId, title, message) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneAction.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneAction.kt new file mode 100644 index 00000000..5be23003 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneAction.kt @@ -0,0 +1,33 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAPaneAction( + val elementId: String, + val title: String, + val imageUrl: String? = null, + val imageData: ByteArray? = null, + val imageTint: FAAImageTint? = null, + val isPrimary: Boolean = false, + val isOnPressListenerActive: Boolean = false, +) { + companion object { + fun fromJson(map: Map): FAAPaneAction { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val imageUrl = map["imageUrl"] as? String + val imageData = map["imageData"] as? ByteArray + val imageTint = FAAImageTint.fromJson(map["imageTint"] as? Map) + val isPrimary = map["isPrimary"] as? Boolean ?: false + val isOnPressListenerActive = map["onPress"] as? Boolean ?: false + + return FAAPaneAction( + elementId, + title, + imageUrl, + imageData, + imageTint, + isPrimary, + isOnPressListenerActive, + ) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneItem.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneItem.kt new file mode 100644 index 00000000..03e6adaf --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneItem.kt @@ -0,0 +1,23 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAPaneItem( + val elementId: String, + val title: String, + val detail: String? = null, + val imageUrl: String? = null, + val imageData: ByteArray? = null, + val imageTint: FAAImageTint? = null, +) { + companion object { + fun fromJson(map: Map): FAAPaneItem { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val detail = map["detail"] as? String + val imageUrl = map["imageUrl"] as? String + val imageData = map["imageData"] as? ByteArray + val imageTint = FAAImageTint.fromJson(map["imageTint"] as? Map) + + return FAAPaneItem(elementId, title, detail, imageUrl, imageData, imageTint) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneTemplate.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneTemplate.kt new file mode 100644 index 00000000..395fac6e --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/pane/FAAPaneTemplate.kt @@ -0,0 +1,42 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAAPaneTemplate( + val elementId: String, + val title: String, + val items: List, + val actions: List, + val imageUrl: String? = null, + val imageData: ByteArray? = null, + val imageTint: FAAImageTint? = null, + val isLoading: Boolean = false, +) { + companion object { + fun fromJson(map: Map): FAAPaneTemplate { + val elementId = map["_elementId"] as? String ?: "" + val title = map["title"] as? String ?: "" + val items = (map["items"] as? List<*>)?.mapNotNull { + (it as? Map<*, *>)?.mapKeys { entry -> entry.key.toString() } + ?.let { FAAPaneItem.fromJson(it) } + } ?: emptyList() + val actions = (map["actions"] as? List<*>)?.mapNotNull { + (it as? Map<*, *>)?.mapKeys { entry -> entry.key.toString() } + ?.let { FAAPaneAction.fromJson(it) } + } ?: emptyList() + val imageUrl = map["imageUrl"] as? String + val imageData = map["imageData"] as? ByteArray + val imageTint = FAAImageTint.fromJson(map["imageTint"] as? Map) + val isLoading = map["isLoading"] as? Boolean ?: false + + return FAAPaneTemplate( + elementId, + title, + items, + actions, + imageUrl, + imageData, + imageTint, + isLoading, + ) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/tabbar/FAATabBarItem.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/tabbar/FAATabBarItem.kt new file mode 100644 index 00000000..a94e6a59 --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/tabbar/FAATabBarItem.kt @@ -0,0 +1,26 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAATabBarItem( + val elementId: String, + val runtimeType: String, + val templateData: Map, + val tabTitle: String, + val systemIcon: String?, + val iconUrl: String?, +) { + companion object { + fun fromJson(map: Map): FAATabBarItem { + val elementId = map["elementId"] as? String ?: "" + val runtimeType = map["runtimeType"] as? String ?: "" + val templateData = (map["template"] as? Map<*, *>) + ?.mapKeys { it.key.toString() } ?: emptyMap() + val tabTitle = (templateData["tabTitle"] as? String) + ?: (templateData["title"] as? String) + ?: "" + val systemIcon = templateData["systemIcon"] as? String + val iconUrl = templateData["iconUrl"] as? String + + return FAATabBarItem(elementId, runtimeType, templateData, tabTitle, systemIcon, iconUrl) + } + } +} diff --git a/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/tabbar/FAATabBarTemplate.kt b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/tabbar/FAATabBarTemplate.kt new file mode 100644 index 00000000..9c43702c --- /dev/null +++ b/third_party/flutter_carplay/android/src/main/kotlin/com/oguzhnatly/flutter_android_auto/models/tabbar/FAATabBarTemplate.kt @@ -0,0 +1,22 @@ +package com.oguzhnatly.flutter_android_auto + +data class FAATabBarTemplate( + val elementId: String, + val tabs: List, +) { + companion object { + fun fromJson(map: Map): FAATabBarTemplate { + val elementId = map["_elementId"] as? String ?: "" + val tabs = (map["tabs"] as? List<*>)?.mapNotNull { + (it as? Map<*, *>)?.mapKeys { entry -> entry.key.toString() } + ?.let { FAATabBarItem.fromJson(it) } + } ?: emptyList() + tabs.forEach { tab -> + if (tab.runtimeType == "FAAListTemplate") { + FAAListTemplate.fromJson(tab.templateData) + } + } + return FAATabBarTemplate(elementId, tabs) + } + } +} diff --git a/third_party/flutter_carplay/android/src/test/kotlin/com/oguzhnatly/flutter_android_auto/FAATabBarTemplateTest.kt b/third_party/flutter_carplay/android/src/test/kotlin/com/oguzhnatly/flutter_android_auto/FAATabBarTemplateTest.kt new file mode 100644 index 00000000..a1c6456f --- /dev/null +++ b/third_party/flutter_carplay/android/src/test/kotlin/com/oguzhnatly/flutter_android_auto/FAATabBarTemplateTest.kt @@ -0,0 +1,60 @@ +package com.oguzhnatly.flutter_android_auto + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class FAATabBarTemplateTest { + @Test + fun `rejects an invalid selectable list in a non-active tab`() { + val exception = assertThrows(IllegalArgumentException::class.java) { + FAATabBarTemplate.fromJson( + mapOf( + "_elementId" to "tabs", + "tabs" to listOf( + listTab("first", listOf(listSection())), + listTab( + "invalid", + listOf( + listSection( + title = "Options", + selectedIndex = 0, + ), + ), + ), + ), + ), + ) + } + + assertEquals( + "A selectable AAListSection must be the only section in an AAListTemplate and must not have a title.", + exception.message, + ) + } + + private fun listTab(id: String, sections: List>) = mapOf( + "elementId" to id, + "runtimeType" to "FAAListTemplate", + "template" to mapOf( + "_elementId" to id, + "title" to id, + "sections" to sections, + ), + ) + + private fun listSection( + title: String = "", + selectedIndex: Int? = null, + ) = mapOf( + "_elementId" to "section-$title", + "title" to title, + "selectedIndex" to selectedIndex, + "items" to listOf( + mapOf( + "_elementId" to "item-$title", + "title" to "Item", + ), + ), + ) +} diff --git a/third_party/flutter_carplay/android/src/test/kotlin/com/oguzhnatly/flutter_android_auto/MethodCallResultTest.kt b/third_party/flutter_carplay/android/src/test/kotlin/com/oguzhnatly/flutter_android_auto/MethodCallResultTest.kt new file mode 100644 index 00000000..03a0912e --- /dev/null +++ b/third_party/flutter_carplay/android/src/test/kotlin/com/oguzhnatly/flutter_android_auto/MethodCallResultTest.kt @@ -0,0 +1,79 @@ +package com.oguzhnatly.flutter_android_auto + +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MethodCallResultTest { + @Test + fun `successful call completes the result exactly once`() = runBlocking { + val result = RecordingResult() + + CoroutineScope(coroutineContext).launchMethodCall(result) { true }.join() + + assertEquals(listOf(true), result.successes) + assertTrue(result.errors.isEmpty()) + assertEquals(1, result.completionCount) + } + + @Test + fun `failed call reports the exception exactly once`() = runBlocking { + val result = RecordingResult() + + CoroutineScope(coroutineContext).launchMethodCall(result) { + throw IllegalStateException("Template creation failed") + }.join() + + assertTrue(result.successes.isEmpty()) + assertEquals(1, result.errors.size) + assertEquals("android_auto_error", result.errors.single().code) + assertEquals("Template creation failed", result.errors.single().message) + assertEquals(1, result.completionCount) + } + + @Test + fun `cancelled call reports cancellation exactly once`() = runBlocking { + val result = RecordingResult() + + CoroutineScope(coroutineContext).launchMethodCall(result) { + throw CancellationException("Plugin detached") + }.join() + + assertTrue(result.successes.isEmpty()) + assertEquals(1, result.errors.size) + assertEquals("operation_cancelled", result.errors.single().code) + assertEquals("Plugin detached", result.errors.single().message) + assertEquals(1, result.completionCount) + } +} + +private class RecordingResult : MethodChannel.Result { + data class Error( + val code: String, + val message: String?, + val details: Any?, + ) + + val successes = mutableListOf() + val errors = mutableListOf() + var notImplementedCount = 0 + + val completionCount: Int + get() = successes.size + errors.size + notImplementedCount + + override fun success(result: Any?) { + successes.add(result) + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + errors.add(Error(errorCode, errorMessage, errorDetails)) + } + + override fun notImplemented() { + notImplementedCount++ + } +} diff --git a/third_party/flutter_carplay/lib/aa_models/alert/alert_action.dart b/third_party/flutter_carplay/lib/aa_models/alert/alert_action.dart new file mode 100644 index 00000000..b050ac9a --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/alert/alert_action.dart @@ -0,0 +1,37 @@ +import 'package:uuid/uuid.dart'; + +/// Display styles for an alert action button on Android Auto. +/// +/// - [normal] — default appearance, no background color applied. +/// - [cancel] — no native equivalent in the Car App Library; renders the same as [normal]. +/// - [destructive] — renders with a red background (`CarColor.RED`). +enum AAAlertActionStyle { normal, cancel, destructive } + +/// An action that can be performed from an [AAAlertTemplate] on Android Auto. +class AAAlertAction { + final String _elementId; + + /// The button label. + final String title; + + /// Visual style hint for the button. + final AAAlertActionStyle style; + + /// Called when the user taps this action. + final Function() onPress; + + AAAlertAction({ + required this.title, + this.style = AAAlertActionStyle.normal, + required this.onPress, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + String get uniqueId => _elementId; + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'style': style.name, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/alert/alert_template.dart b/third_party/flutter_carplay/lib/aa_models/alert/alert_template.dart new file mode 100644 index 00000000..40d9daf4 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/alert/alert_template.dart @@ -0,0 +1,49 @@ +import 'package:uuid/uuid.dart'; + +import '../template.dart'; +import 'alert_action.dart'; + +/// A template that presents a modal alert on Android Auto. +/// +/// Rendered as a full-screen [MessageTemplate] from the Car App Library. +/// Unlike CarPlay, Android Auto does not support true overlay modals, so the +/// alert occupies the entire screen and is pushed onto the navigation stack. +/// +/// Use [FlutterAndroidAuto.showAlert] to present it and +/// [FlutterAndroidAuto.popModal] to dismiss it programmatically. +class AAAlertTemplate implements AATemplate { + final String _elementId; + + /// Primary title shown at the top of the alert. + final String title; + + /// Optional body message displayed below the title. + final String? message; + + /// The action buttons available on the alert. + final List actions; + + /// Called when the alert finishes presenting. + /// [completed] is true when the alert was shown successfully. + final Function(bool completed)? onPresent; + + AAAlertTemplate({ + required this.title, + this.message, + required this.actions, + this.onPresent, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + String get uniqueId => _elementId; + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'message': message, + 'actions': actions.map((e) => e.toJson()).toList(), + 'onPresent': onPresent != null, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/alert/all.dart b/third_party/flutter_carplay/lib/aa_models/alert/all.dart new file mode 100644 index 00000000..b09017dd --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/alert/all.dart @@ -0,0 +1,2 @@ +export 'alert_action.dart'; +export 'alert_template.dart'; diff --git a/third_party/flutter_carplay/lib/aa_models/all.dart b/third_party/flutter_carplay/lib/aa_models/all.dart new file mode 100644 index 00000000..b30e8d41 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/all.dart @@ -0,0 +1,8 @@ +export 'alert/all.dart'; +export 'grid/all.dart'; +export 'list/all.dart'; +export 'map/all.dart'; +export 'message/all.dart'; +export 'pane/all.dart'; +export 'tabbar/all.dart'; +export 'template.dart'; diff --git a/third_party/flutter_carplay/lib/aa_models/grid/all.dart b/third_party/flutter_carplay/lib/aa_models/grid/all.dart new file mode 100644 index 00000000..2a5672a9 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/grid/all.dart @@ -0,0 +1,2 @@ +export 'grid_button.dart'; +export 'grid_template.dart'; diff --git a/third_party/flutter_carplay/lib/aa_models/grid/grid_button.dart b/third_party/flutter_carplay/lib/aa_models/grid/grid_button.dart new file mode 100644 index 00000000..b47a3743 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/grid/grid_button.dart @@ -0,0 +1,52 @@ +import 'package:uuid/uuid.dart'; + +/// A single cell in an [AAGridTemplate]. +/// +/// Each button shows a [titleVariants] label and an optional [image]. +/// The first element of [titleVariants] is used as the primary title on +/// Android Auto (Car App Library shows the most appropriate variant +/// according to available space). +class AAGridButton { + /// Unique id of the object. + final String _elementId; + + /// Label variants displayed beneath the cell image. Must not be empty. + final List titleVariants; + + /// Image displayed inside the grid cell. Supports three formats: + /// - **Asset** (pubspec.yaml): `'images/logo.png'` + /// - **Local file**: `'file:///path/to/image.png'` + /// - **Network URL**: `'https://example.com/image.png'` + /// + /// Falls back to a default icon when null or when the load fails. + final String? image; + + /// Text displayed as the loading screen title while [onPress] is executing + /// (until [complete] is called). When null, no title is shown. + final String? loadingMessage; + + /// Callback fired when the user taps this button. + /// + /// - `complete` must be called after processing to dismiss the loading screen + /// and rebuild the template — identical to [AAListItem] behaviour. + /// - `self` is a reference to the tapped button itself. + final Future Function(Function() complete, AAGridButton self)? onPress; + + AAGridButton({ + required this.titleVariants, + this.image, + this.loadingMessage, + this.onPress, + }) : assert(titleVariants.isNotEmpty, 'titleVariants must not be empty'), + _elementId = const Uuid().v4(); + + String get uniqueId => _elementId; + + Map toJson() => { + '_elementId': _elementId, + 'titleVariants': titleVariants, + 'image': image, + 'loadingMessage': loadingMessage, + 'onPress': onPress != null, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/grid/grid_template.dart b/third_party/flutter_carplay/lib/aa_models/grid/grid_template.dart new file mode 100644 index 00000000..b00abdb5 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/grid/grid_template.dart @@ -0,0 +1,66 @@ +import 'package:uuid/uuid.dart'; + +import '../template.dart'; +import 'grid_button.dart'; + +/// A template that displays a grid of tappable cells on Android Auto. +/// +/// Rendered as [GridTemplate] from the Car App Library. Android Auto +/// recommends a maximum of 8 buttons per grid. +/// +/// When used as a tab inside an [AATabBarTemplate], the tab label is taken +/// from [tabTitle] (falling back to [title]). An optional tab icon can be +/// provided via [systemIcon] or [iconUrl]. +class AAGridTemplate implements AATemplate { + /// Unique id of the object. + final String _elementId; + + /// Title displayed in the header of the template. + final String title; + + /// The grid cells. Android Auto recommends a maximum of 8 items. + final List buttons; + + /// Messages displayed when [buttons] is empty. + /// + /// Android Auto uses the first element as the text passed to + /// `ItemList.Builder.setNoItemsMessage()`. When [buttons] is empty and + /// this list is null or empty, the template shows a loading indicator. + final List? emptyViewTitleVariants; + + /// Label displayed on the tab bar item when this template is used as a tab + /// inside an [AATabBarTemplate]. Falls back to [title] when not set. + final String? tabTitle; + + /// SF Symbol / icon name used to resolve a [CarIcon] for the tab bar item. + /// Common names such as "map", "house", "star" are mapped to Car App + /// Library built-in icons. Unknown names fall back to a default. + final String? systemIcon; + + /// URL of an image to use as the tab bar icon. Loaded asynchronously. + /// Takes precedence over [systemIcon] when both are set. + final String? iconUrl; + + AAGridTemplate({ + required this.title, + required this.buttons, + this.emptyViewTitleVariants, + this.tabTitle, + this.systemIcon, + this.iconUrl, + }) : _elementId = const Uuid().v4(); + + @override + String get uniqueId => _elementId; + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'buttons': buttons.map((b) => b.toJson()).toList(), + 'emptyViewTitleVariants': emptyViewTitleVariants, + 'tabTitle': tabTitle, + 'systemIcon': systemIcon, + 'iconUrl': iconUrl, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/list/all.dart b/third_party/flutter_carplay/lib/aa_models/list/all.dart new file mode 100644 index 00000000..1e9bac51 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/list/all.dart @@ -0,0 +1,3 @@ +export 'list_item.dart'; +export 'list_section.dart'; +export 'list_template.dart'; diff --git a/third_party/flutter_carplay/lib/aa_models/list/list_item.dart b/third_party/flutter_carplay/lib/aa_models/list/list_item.dart new file mode 100644 index 00000000..1647b987 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/list/list_item.dart @@ -0,0 +1,91 @@ +import 'dart:async'; + +import 'package:flutter_carplay/models/common/image_tint.dart'; +import 'package:uuid/uuid.dart'; + +class AAToggle { + bool isChecked; + final bool? isEnabled; + final Function(bool checked, AAListItem self)? onCheckedChange; + + AAToggle({this.isChecked = false, this.isEnabled, this.onCheckedChange}); + + Map toJson() => { + 'isChecked': isChecked, + 'isEnabled': isEnabled, + 'onCheckedChange': onCheckedChange != null ? true : false, + }; +} + +class AAListItem { + /// Unique id of the object. + final String _elementId; + + final String title; + final String? subtitle; + + /// The image displayed for this row on Android Auto. + /// + /// Supports asset paths, SVG assets, file paths, and network URLs. + final String? imageUrl; + final AutoImageTint? imageTint; + + /// The image displayed on the trailing side of this row when supported by the + /// Android Auto host. + final String? trailingImage; + + /// Optional tint applied to [trailingImage]. + final AutoImageTint? trailingImageTint; + + /// Text displayed as the loading screen title while [onPress] is executing + /// until [complete] is called. When null, no title is shown. + final String? loadingMessage; + + final bool? isBrowsable; + final AAToggle? toggle; + final FutureOr Function(Function() complete, AAListItem self)? onPress; + + AAListItem({ + required this.title, + this.subtitle, + String? image, + String? imageUrl, + this.imageTint, + this.trailingImage, + this.trailingImageTint, + this.loadingMessage, + this.isBrowsable, + this.toggle, + this.onPress, + String? id, + }) : imageUrl = imageUrl ?? image, + assert( + isBrowsable != true || toggle == null, + 'A browsable row must not have a toggle set.', + ), + assert( + isBrowsable != true || onPress != null, + 'A browsable row must have an onClickListener set.', + ), + assert( + toggle == null || onPress == null, + 'If a row contains a toggle, it must not have an onClickListener set.', + ), + _elementId = id ?? const Uuid().v4(); + + String get uniqueId => _elementId; + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'subtitle': subtitle, + 'imageUrl': imageUrl, + 'imageTint': imageTint?.toJson(), + 'trailingImage': trailingImage, + 'trailingImageTint': trailingImageTint?.toJson(), + 'loadingMessage': loadingMessage, + 'isBrowsable': isBrowsable, + 'toggle': toggle?.toJson(), + 'onPress': onPress != null ? true : false, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/list/list_section.dart b/third_party/flutter_carplay/lib/aa_models/list/list_section.dart new file mode 100644 index 00000000..d622566f --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/list/list_section.dart @@ -0,0 +1,54 @@ +import 'package:uuid/uuid.dart'; + +import 'list_item.dart'; + +class AAListSection { + /// Unique id of the object. + final String _elementId; + + /// Required only when multiple [AAListSection] are used in the same [AAListTemplate]. + final String? title; + + final List items; + int? selectedIndex; + final Function(int selectedIndex, AAListItem selectedItem)? onSelected; + + AAListSection({ + this.title, + required this.items, + this.selectedIndex, + this.onSelected, + String? id, + }) : assert( + selectedIndex == null || + (selectedIndex >= 0 && selectedIndex < items.length), + 'selectedIndex must be within the list item range.', + ), + assert( + (selectedIndex == null && onSelected == null) || items.isNotEmpty, + 'A selectable list must have at least one item.', + ), + assert( + selectedIndex == null && onSelected == null || + items.every((AAListItem item) => item.onPress == null), + 'Selectable list items must not have an onClickListener set.', + ), + assert( + selectedIndex == null && onSelected == null || + items.every((AAListItem item) => item.toggle == null), + 'Selectable list items must not have a toggle set.', + ), + _elementId = id ?? const Uuid().v4(); + + String get uniqueId => _elementId; + + bool get isSelectable => selectedIndex != null || onSelected != null; + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'items': items.map((AAListItem item) => item.toJson()).toList(), + 'selectedIndex': selectedIndex, + 'onSelected': onSelected != null ? true : false, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/list/list_template.dart b/third_party/flutter_carplay/lib/aa_models/list/list_template.dart new file mode 100644 index 00000000..9a0c06a7 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/list/list_template.dart @@ -0,0 +1,91 @@ +import 'package:uuid/uuid.dart'; + +import '../template.dart'; +import 'list_section.dart'; + +class AAListTemplate implements AATemplate { + /// Unique id of the object. + final String _elementId; + + final String title; + final List sections; + + /// An array of title variants displayed when the list is empty. + /// + /// Android Auto uses the first element as the message passed to + /// `ItemList.Builder.setNoItemsMessage()`. When [sections] is empty and + /// this list is null, the template falls back to a loading indicator. + final List? emptyViewTitleVariants; + + /// Label displayed on the tab bar item when this template is used as a tab + /// inside an [AATabBarTemplate]. Falls back to [title] when not set. + final String? tabTitle; + + /// Icon displayed in the tab when this template is used inside an + /// [AATabBarTemplate]. Supports mapped system icon names, Flutter assets, + /// local files, and network URLs. + /// + /// When [iconUrl] is also set, [iconUrl] takes precedence. + final String? systemIcon; + + /// URL of an image to use as the tab bar icon. Loaded asynchronously. + /// Takes precedence over [systemIcon] when set. + final String? iconUrl; + + AAListTemplate({ + required this.title, + required this.sections, + this.emptyViewTitleVariants, + this.tabTitle, + this.systemIcon, + this.iconUrl, + String? id, + }) : assert( + _hasValidSelectableList(sections), + 'A selectable AAListSection must be the only section in an ' + 'AAListTemplate and must not have a title.', + ), + _elementId = id ?? const Uuid().v4(); + + @override + String get uniqueId => _elementId; + + static bool _hasValidSelectableList(List sections) { + final selectableSections = sections.where( + (AAListSection section) => section.isSelectable, + ); + if (selectableSections.isEmpty) return true; + + return sections.length == 1 && + (selectableSections.single.title == null || + selectableSections.single.title!.isEmpty); + } + + static void _validateSelectableList(List sections) { + assert( + _hasValidSelectableList(sections), + 'A selectable AAListSection must be the only section in an ' + 'AAListTemplate and must not have a title.', + ); + } + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'sections': + sections.map((AAListSection section) => section.toJson()).toList(), + 'emptyViewTitleVariants': emptyViewTitleVariants, + 'tabTitle': tabTitle, + 'systemIcon': systemIcon, + 'iconUrl': iconUrl, + }; + + void updateSections(List newSections) { + _validateSelectableList(newSections); + final copy = List.from(newSections); + sections + ..clear() + ..addAll(copy); + } +} diff --git a/third_party/flutter_carplay/lib/aa_models/map/all.dart b/third_party/flutter_carplay/lib/aa_models/map/all.dart new file mode 100644 index 00000000..6f5d219e --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/map/all.dart @@ -0,0 +1,2 @@ +export 'map_action.dart'; +export 'map_with_content_template.dart'; diff --git a/third_party/flutter_carplay/lib/aa_models/map/map_action.dart b/third_party/flutter_carplay/lib/aa_models/map/map_action.dart new file mode 100644 index 00000000..35312740 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/map/map_action.dart @@ -0,0 +1,42 @@ +import 'package:uuid/uuid.dart'; + +/// DELTA D from upstream 1.6.5: an action in a map action strip. +/// +/// The strip is the vertical bar the host draws down the right edge of the map +/// region. Its constraints are stricter than a pane's — `ACTIONS_CONSTRAINTS_MAP` +/// allows at most 4 actions and 1 primary, and leaves `maxCustomTitles` at 0, so +/// **an action without an icon is rejected**. [imageUrl] is therefore required, +/// not decorative; [title] is carried only for accessibility and for the +/// host to use if it ever renders one. +class AAMapAction { + AAMapAction({ + required this.title, + required this.imageUrl, + this.isPrimary = false, + this.onPress, + String? id, + }) : assert(title.isNotEmpty, 'AAMapAction.title cannot be empty'), + assert(imageUrl.isNotEmpty, + 'AAMapAction.imageUrl is required — the map strip is icon-only'), + _elementId = id ?? const Uuid().v4(); + + final String _elementId; + final String title; + + /// Flutter asset path, file:// path or network URL — resolved to a CarIcon + /// natively by the same loader pane and grid images use. + final String imageUrl; + + final bool isPrimary; + final Function()? onPress; + + String get uniqueId => _elementId; + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'imageUrl': imageUrl, + 'isPrimary': isPrimary, + 'onPress': onPress != null, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/map/map_with_content_template.dart b/third_party/flutter_carplay/lib/aa_models/map/map_with_content_template.dart new file mode 100644 index 00000000..db338e7a --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/map/map_with_content_template.dart @@ -0,0 +1,56 @@ +import 'package:uuid/uuid.dart'; + +import '../template.dart'; +import 'map_action.dart'; + +/// DELTA D from upstream 1.6.5: Android Auto's MapWithContentTemplate. +/// +/// The host renders no map here. It reserves a map region and hands the app a +/// Surface through `AppManager.setSurfaceCallback`; whatever the app draws onto +/// that Surface is the map. See `FAASurfaceProvider` on the native side. +/// +/// [contentTemplate] is any supported template — it is dispatched through the +/// same builder as a root template — and occupies the content half beside the +/// map. +/// +/// Requires car API level 7, the `androidx.car.app.MAP_TEMPLATES` and +/// `androidx.car.app.ACCESS_SURFACE` permissions, and an app category permitted +/// to draw maps (navigation, POI, or weather — **not** IOT). +class AAMapWithContentTemplate implements AATemplate { + AAMapWithContentTemplate({ + required this.contentTemplate, + this.mapActions = const [], + String? id, + }) : assert(mapActions.length <= 4, + 'A map action strip cannot hold more than 4 actions'), + assert( + mapActions.where((AAMapAction a) => a.isPrimary).length <= 1, + 'A map action strip cannot hold more than 1 primary action', + ), + _elementId = id ?? const Uuid().v4(); + + final String _elementId; + final AATemplate contentTemplate; + + /// The vertical strip down the right edge of the map. Empty means no strip. + final List mapActions; + + @override + String get uniqueId => _elementId; + + /// The native side needs the content's runtime type to dispatch it, exactly + /// as the root template does. + static String runtimeTypeOf(AATemplate template) { + final name = template.runtimeType.toString(); + return name.startsWith('AA') ? 'FAA${name.substring(2)}' : 'FAA$name'; + } + + @override + Map toJson() => { + '_elementId': _elementId, + 'contentRuntimeType': runtimeTypeOf(contentTemplate), + 'contentTemplate': contentTemplate.toJson(), + 'mapActions': + mapActions.map((AAMapAction action) => action.toJson()).toList(), + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/message/all.dart b/third_party/flutter_carplay/lib/aa_models/message/all.dart new file mode 100644 index 00000000..96ab4f18 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/message/all.dart @@ -0,0 +1,2 @@ +export 'long_message_template.dart'; +export 'message_template.dart'; diff --git a/third_party/flutter_carplay/lib/aa_models/message/long_message_template.dart b/third_party/flutter_carplay/lib/aa_models/message/long_message_template.dart new file mode 100644 index 00000000..a68c3264 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/message/long_message_template.dart @@ -0,0 +1,18 @@ +import '../../constants/private_constants.dart'; +import 'message_template_base.dart'; + +class AALongMessageTemplate extends AAMessageTemplateBase { + /// Creates a long message template for Android Auto. + /// + /// [message] must not be empty because Android Auto requires a non-empty + /// message when building the native template. + AALongMessageTemplate({ + required super.title, + required super.message, + super.id, + }); + + @override + FAAChannelTypes get updateChannelType => + FAAChannelTypes.updateLongMessageTemplate; +} diff --git a/third_party/flutter_carplay/lib/aa_models/message/message_template.dart b/third_party/flutter_carplay/lib/aa_models/message/message_template.dart new file mode 100644 index 00000000..470a78d7 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/message/message_template.dart @@ -0,0 +1,14 @@ +import '../../constants/private_constants.dart'; +import 'message_template_base.dart'; + +class AAMessageTemplate extends AAMessageTemplateBase { + /// Creates a message template for Android Auto. + /// + /// [message] must not be empty because Android Auto requires a non-empty + /// message when building the native template. + AAMessageTemplate({required super.title, required super.message, super.id}); + + @override + FAAChannelTypes get updateChannelType => + FAAChannelTypes.updateMessageTemplate; +} diff --git a/third_party/flutter_carplay/lib/aa_models/message/message_template_base.dart b/third_party/flutter_carplay/lib/aa_models/message/message_template_base.dart new file mode 100644 index 00000000..cc807e75 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/message/message_template_base.dart @@ -0,0 +1,76 @@ +import 'package:flutter_carplay/controllers/android_auto_controller.dart'; +import 'package:uuid/uuid.dart'; + +import '../../constants/private_constants.dart'; +import '../template.dart'; + +abstract class AAMessageTemplateBase implements AATemplate { + /// Unique id of the object. + final String _elementId; + + String title; + String message; + + AAMessageTemplateBase({ + required this.title, + required this.message, + String? id, + }) : _elementId = id ?? const Uuid().v4() { + _validateMessage(message); + } + + FAAChannelTypes get updateChannelType; + + @override + String get uniqueId => _elementId; + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'message': message, + }; + + /// Updates the template content on Android Auto. + /// + /// Android Auto templates are immutable. This method asks the native side to + /// rebuild the message template and invalidate the current screen. Changing + /// the title or message is treated by Android Auto as a new template step, + /// not as a refresh of the existing template, and can count toward host + /// template limits. + /// + /// [message] must not be empty. Native update errors are surfaced as + /// [PlatformException]s from the MethodChannel call. + Future update({String? title, String? message}) async { + final nextTitle = title ?? this.title; + final nextMessage = message ?? this.message; + _validateMessage(nextMessage); + + await FlutterAndroidAutoController.flutterToNativeModuleStatic( + updateChannelType, + {'elementId': _elementId, 'title': nextTitle, 'message': nextMessage}, + ); + + updateTemplate(title: nextTitle, message: nextMessage); + } + + Future setTitle(String title) { + return update(title: title); + } + + Future setMessage(String message) { + return update(message: message); + } + + void updateTemplate({required String title, required String message}) { + _validateMessage(message); + this.title = title; + this.message = message; + } + + static void _validateMessage(String message) { + if (message.isEmpty) { + throw ArgumentError.value(message, 'message', 'Message cannot be empty'); + } + } +} diff --git a/third_party/flutter_carplay/lib/aa_models/pane/all.dart b/third_party/flutter_carplay/lib/aa_models/pane/all.dart new file mode 100644 index 00000000..862d910f --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/pane/all.dart @@ -0,0 +1,3 @@ +export 'pane_action.dart'; +export 'pane_item.dart'; +export 'pane_template.dart'; diff --git a/third_party/flutter_carplay/lib/aa_models/pane/pane_action.dart b/third_party/flutter_carplay/lib/aa_models/pane/pane_action.dart new file mode 100644 index 00000000..103a4b8a --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/pane/pane_action.dart @@ -0,0 +1,39 @@ +import 'package:flutter_carplay/models/common/image_tint.dart'; +import 'package:uuid/uuid.dart'; + +class AAPaneAction { + /// Unique id of the object. + final String _elementId; + + final String title; + + /// Optional icon displayed with this pane action on Android Auto. + /// + /// Supports the same formats as [AAPaneItem.imageUrl], including Flutter + /// asset SVGs rasterized before the payload is sent to native Android. + final String? imageUrl; + final AutoImageTint? imageTint; + final bool isPrimary; + final Function()? onPress; + + AAPaneAction({ + required this.title, + this.imageUrl, + this.imageTint, + this.isPrimary = false, + this.onPress, + String? id, + }) : assert(title.isNotEmpty, 'AAPaneAction.title cannot be empty'), + _elementId = id ?? const Uuid().v4(); + + String get uniqueId => _elementId; + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'imageUrl': imageUrl, + 'imageTint': imageTint?.toJson(), + 'isPrimary': isPrimary, + 'onPress': onPress != null, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/pane/pane_item.dart b/third_party/flutter_carplay/lib/aa_models/pane/pane_item.dart new file mode 100644 index 00000000..3a9d9b2d --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/pane/pane_item.dart @@ -0,0 +1,40 @@ +import 'package:flutter_carplay/models/common/image_tint.dart'; +import 'package:uuid/uuid.dart'; + +class AAPaneItem { + /// Unique id of the object. + final String _elementId; + + final String title; + final String? detail; + + /// The image displayed for this informational row on Android Auto. + /// + /// Supports these formats: + /// - **Asset path**: `images/flutter_logo.png` (from pubspec.yaml assets) + /// - **SVG asset**: `images/icon.svg` (rasterized to PNG before being sent to + /// the native side; remote/`file://` SVGs are not supported) + /// - **File path**: `file:///path/to/image.png` (local file on device) + /// - **Network URL**: `https://example.com/image.png` (remote image) + final String? imageUrl; + final AutoImageTint? imageTint; + + AAPaneItem({ + required this.title, + this.detail, + this.imageUrl, + this.imageTint, + String? id, + }) : assert(title.isNotEmpty, 'AAPaneItem.title cannot be empty'), + _elementId = id ?? const Uuid().v4(); + + String get uniqueId => _elementId; + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'detail': detail, + 'imageUrl': imageUrl, + 'imageTint': imageTint?.toJson(), + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/pane/pane_template.dart b/third_party/flutter_carplay/lib/aa_models/pane/pane_template.dart new file mode 100644 index 00000000..6825732e --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/pane/pane_template.dart @@ -0,0 +1,80 @@ +import 'package:flutter_carplay/models/common/image_tint.dart'; +import 'package:uuid/uuid.dart'; + +import '../template.dart'; +import 'pane_action.dart'; +import 'pane_item.dart'; + +class AAPaneTemplate implements AATemplate { + /// Unique id of the object. + final String _elementId; + + /// DELTA D from upstream 1.6.5: optional, and empty by default. + /// + /// Upstream requires a non-empty title, but `PaneTemplate.Builder.build()` + /// does not — it validates only the pane's rows and actions. The title is what + /// draws the header, so an empty one means no header, which matters when the + /// pane is the content half of a MapWithContentTemplate and every row of + /// chrome is taken from the map. + final String title; + final List items; + final List actions; + + /// Optional image displayed alongside the pane content. + /// + /// Supports the same formats as [AAPaneItem.imageUrl], including Flutter + /// asset SVGs rasterized before the payload is sent to native Android. + final String? imageUrl; + final AutoImageTint? imageTint; + + /// Shows Android Auto's loading state instead of rows. + /// + /// Android requires pane content to be either loading with no rows, or + /// non-loading with at least one row. + /// + /// Android treats a loading pane followed by loaded content as a template + /// refresh. Switching an already-loaded pane back to loading changes the row + /// count, so it may count as a new template in Android Auto's template quota. + final bool isLoading; + + AAPaneTemplate({ + this.title = '', + required this.items, + this.actions = const [], + this.imageUrl, + this.imageTint, + this.isLoading = false, + String? id, + }) : assert( + isLoading || items.isNotEmpty, + 'AAPaneTemplate.items cannot be empty unless isLoading is true', + ), + assert( + !isLoading || items.isEmpty, + 'AAPaneTemplate.items must be empty when isLoading is true', + ), + assert( + actions.length <= 2, + 'AAPaneTemplate.actions cannot contain more than 2 actions', + ), + assert( + actions.where((AAPaneAction action) => action.isPrimary).length <= 1, + 'AAPaneTemplate.actions cannot contain more than 1 primary action', + ), + _elementId = id ?? const Uuid().v4(); + + @override + String get uniqueId => _elementId; + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'items': items.map((AAPaneItem item) => item.toJson()).toList(), + 'actions': + actions.map((AAPaneAction action) => action.toJson()).toList(), + 'imageUrl': imageUrl, + 'imageTint': imageTint?.toJson(), + 'isLoading': isLoading, + }; +} diff --git a/third_party/flutter_carplay/lib/aa_models/tabbar/all.dart b/third_party/flutter_carplay/lib/aa_models/tabbar/all.dart new file mode 100644 index 00000000..b9ce2e6a --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/tabbar/all.dart @@ -0,0 +1 @@ +export 'tabbar_template.dart'; diff --git a/third_party/flutter_carplay/lib/aa_models/tabbar/tabbar_template.dart b/third_party/flutter_carplay/lib/aa_models/tabbar/tabbar_template.dart new file mode 100644 index 00000000..5a248392 --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/tabbar/tabbar_template.dart @@ -0,0 +1,59 @@ +import 'package:uuid/uuid.dart'; + +import '../grid/grid_template.dart'; +import '../list/list_template.dart'; +import '../template.dart'; + +/// A container template that displays multiple child templates as tabs on +/// Android Auto. Rendered as [TabTemplate] from the Car App Library (API 6+). +/// +/// Each child [AATemplate] is shown in its own tab. The tab's label is taken +/// from [AAListTemplate.tabTitle] (falling back to [AAListTemplate.title]). +/// An optional tab icon can be provided via [AAListTemplate.systemIcon] +/// (mapped to a CarIcon built-in) or [AAListTemplate.iconUrl] (loaded async). +/// +/// Currently supported child template types: [AAListTemplate]. +/// +/// Devices that do not support API level 6 will fall back to showing the first +/// tab's content as a plain [ListTemplate]. +class AATabBarTemplate implements AATemplate { + final String _elementId; + + /// The templates shown in each tab (max 5, per Android Auto restrictions). + final List tabs; + + AATabBarTemplate({ + required List tabs, + String? id, + }) : tabs = List.from(tabs), + _elementId = id ?? const Uuid().v4(); + + @override + String get uniqueId => _elementId; + + @override + Map toJson() => { + '_elementId': _elementId, + 'tabs': tabs + .map((t) => { + 'elementId': t.uniqueId, + 'runtimeType': _runtimeTypeOf(t), + 'template': t.toJson(), + }) + .toList(), + }; + + /// Updates the tabs list in-place (mirrors CPTabBarTemplate.updateTemplates). + void updateTabs(List newTabs) { + final copy = List.from(newTabs); + tabs + ..clear() + ..addAll(copy); + } + + static String _runtimeTypeOf(AATemplate t) { + if (t is AAListTemplate) return 'FAAListTemplate'; + if (t is AAGridTemplate) return 'FAAGridTemplate'; + return 'FAAUnknown'; + } +} diff --git a/third_party/flutter_carplay/lib/aa_models/template.dart b/third_party/flutter_carplay/lib/aa_models/template.dart new file mode 100644 index 00000000..51cd407f --- /dev/null +++ b/third_party/flutter_carplay/lib/aa_models/template.dart @@ -0,0 +1,7 @@ +abstract interface class AATemplate { + const AATemplate(); + + String get uniqueId; + + Map toJson(); +} diff --git a/third_party/flutter_carplay/lib/android_auto_worker.dart b/third_party/flutter_carplay/lib/android_auto_worker.dart new file mode 100644 index 00000000..12888ed7 --- /dev/null +++ b/third_party/flutter_carplay/lib/android_auto_worker.dart @@ -0,0 +1,268 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_carplay/flutter_carplay.dart'; + +import 'controllers/android_auto_controller.dart'; + +/// An object used to integrate Android Auto navigation and manage user +/// interface elements displayed on the Android Auto screen. +class FlutterAndroidAuto { + static final FlutterAndroidAutoController _androidAutoController = + FlutterAndroidAutoController(); + + late final StreamSubscription? _eventBroadcast; + + static String _connectionStatus = ConnectionStatusTypes.unknown.name; + + /// The size used when rasterizing Flutter asset SVGs referenced by image + /// fields before they are sent to the native side. + static int svgRasterSize = defaultSvgRasterSize; + + Function(ConnectionStatusTypes status)? _onAndroidAutoConnectionChange; + + FlutterAndroidAuto() { + if (defaultTargetPlatform != TargetPlatform.android) return; + + _eventBroadcast = _androidAutoController.eventChannel + .receiveBroadcastStream() + .listen((event) async { + final FAAChannelTypes receivedChannelType = EnumUtils.enumFromString( + FAAChannelTypes.values, + event['type'], + ); + + switch (receivedChannelType) { + case FAAChannelTypes.onAndroidAutoConnectionChange: + final ConnectionStatusTypes connectionStatus = + EnumUtils.enumFromString( + ConnectionStatusTypes.values, + event['data']['status'], + ); + _connectionStatus = connectionStatus.name; + _onAndroidAutoConnectionChange?.call(connectionStatus); + break; + + case FAAChannelTypes.onListItemSelected: + await _androidAutoController.processFAAListItemSelectedChannel( + event['data']['elementId'], + ); + break; + + case FAAChannelTypes.onListSectionSelected: + _androidAutoController.processFAAListSectionSelectedChannel( + event['data']['elementId'], + event['data']['selectedIndex'], + ); + break; + + case FAAChannelTypes.onToggleCheckedChange: + _androidAutoController.processFAAToggleCheckedChangeChannel( + event['data']['elementId'], + event['data']['checked'], + ); + break; + + case FAAChannelTypes.onPaneActionPressed: + _androidAutoController.processFAAPaneActionPressedChannel( + event['data']['elementId'], + ); + break; + + case FAAChannelTypes.onMapActionPressed: + _androidAutoController.processFAAMapActionPressedChannel( + event['data']['elementId'], + ); + break; + + case FAAChannelTypes.onScreenBackButtonPressed: + FlutterAndroidAutoController.templateHistory.removeWhere( + (AATemplate item) => item.uniqueId == event['data']['elementId'], + ); + break; + + case FAAChannelTypes.onAlertActionPressed: + _androidAutoController.processFAAAlertActionPressed( + event['data']['elementId'], + ); + break; + + case FAAChannelTypes.onPresentStateChanged: + final bool completed = event['data']['completed'] as bool? ?? false; + _androidAutoController.processFAAPresentStateChanged( + event['data']['elementId'], + completed, + ); + break; + + case FAAChannelTypes.onTabBarItemSelected: + break; + + case FAAChannelTypes.onGridButtonPressed: + await _androidAutoController.processFAAGridButtonPressed( + event['data']['elementId'], + ); + break; + + default: + break; + } + }); + } + + void closeConnection() { + _eventBroadcast!.cancel(); + } + + void resumeConnection() { + _eventBroadcast!.resume(); + } + + void pauseConnection() { + _eventBroadcast!.pause(); + } + + void addListenerOnConnectionChange( + Function(ConnectionStatusTypes status) onAndroidAutoConnectionChange, + ) { + _onAndroidAutoConnectionChange = onAndroidAutoConnectionChange; + } + + void removeListenerOnConnectionChange() { + _onAndroidAutoConnectionChange = null; + } + + static String get connectionStatus => _connectionStatus; + + static Future setRootTemplate({required AATemplate template}) async { + final bool? isCompleted = await _androidAutoController + .flutterToNativeModule(FAAChannelTypes.setRootTemplate, { + 'template': template.toJson(), + 'runtimeType': _getAARuntimeTypeString(template), + }); + + if (isCompleted == true) { + if (FlutterAndroidAutoController.templateHistory.isEmpty) { + FlutterAndroidAutoController.templateHistory.add(template); + } else { + FlutterAndroidAutoController.templateHistory[0] = template; + } + } + } + + Future updateListTemplateSections({ + required String elementId, + required List sections, + }) { + return FlutterAndroidAutoController.updateAAListTemplateSections( + elementId: elementId, + sections: sections, + ); + } + + static Future updatePaneTemplate({ + required AAPaneTemplate template, + }) async { + final bool? isCompleted = await _androidAutoController + .flutterToNativeModule(FAAChannelTypes.updatePaneTemplate, { + 'template': template.toJson(), + }); + + if (isCompleted == true) { + final int index = FlutterAndroidAutoController.templateHistory.indexWhere( + (AATemplate item) => item.uniqueId == template.uniqueId, + ); + if (index != -1) { + FlutterAndroidAutoController.templateHistory[index] = template; + } + } + + return isCompleted ?? false; + } + + Future forceUpdateRootTemplate() { + return _androidAutoController.flutterToNativeModule( + FAAChannelTypes.forceUpdateRootTemplate, + ); + } + + static dynamic get rootTemplate => + FlutterAndroidAutoController.currentRootTemplate; + + static Future showAlert({ + required AAAlertTemplate template, + }) async { + final bool? isCompleted = await _androidAutoController + .flutterToNativeModule(FAAChannelTypes.setAlert, { + 'template': template.toJson(), + }); + + if (isCompleted == true) { + FlutterAndroidAutoController.currentPresentTemplate = template; + } + template.onPresent?.call(isCompleted ?? false); + } + + static Future popModal() async { + FlutterAndroidAutoController.currentPresentTemplate = null; + final bool? isCompleted = await _androidAutoController + .flutterToNativeModule(FAAChannelTypes.closePresent); + return isCompleted ?? false; + } + + static Future updateTabBarTemplates({ + required AATabBarTemplate template, + }) async { + await _androidAutoController + .flutterToNativeModule(FAAChannelTypes.updateTabBarTemplates, { + 'template': template.toJson(), + }); + + final index = FlutterAndroidAutoController.templateHistory + .indexWhere((item) => item.uniqueId == template.uniqueId); + if (index >= 0) { + FlutterAndroidAutoController.templateHistory[index] = template; + } + } + + static Future pop() async { + final bool? isCompleted = await _androidAutoController + .flutterToNativeModule(FAAChannelTypes.popTemplate); + return isCompleted ?? false; + } + + static Future popToRoot() async { + final bool? isCompleted = await _androidAutoController + .flutterToNativeModule(FAAChannelTypes.popToRootTemplate); + return isCompleted ?? false; + } + + static Future push({required AATemplate template}) async { + final bool? isCompleted = await _androidAutoController + .flutterToNativeModule(FAAChannelTypes.pushTemplate, { + 'template': template.toJson(), + 'runtimeType': _getAARuntimeTypeString(template), + }); + if (isCompleted == true) { + FlutterAndroidAutoController.templateHistory.add(template); + } + return isCompleted ?? false; + } + + static Future showSharedNowPlaying() async => false; + + static String _getAARuntimeTypeString(AATemplate template) { + if (template is AAListTemplate) return 'FAAListTemplate'; + if (template is AAGridTemplate) return 'FAAGridTemplate'; + if (template is AATabBarTemplate) return 'FAATabBarTemplate'; + if (template is AAPaneTemplate) return 'FAAPaneTemplate'; + // DELTA D + if (template is AAMapWithContentTemplate) { + return 'FAAMapWithContentTemplate'; + } + if (template is AAMessageTemplate) return 'FAAMessageTemplate'; + if (template is AALongMessageTemplate) return 'FAALongMessageTemplate'; + if (template is AAAlertTemplate) return 'FAAAlertTemplate'; + return 'FAA${template.runtimeType}'; + } +} diff --git a/third_party/flutter_carplay/lib/carplay_worker.dart b/third_party/flutter_carplay/lib/carplay_worker.dart new file mode 100644 index 00000000..7186d67e --- /dev/null +++ b/third_party/flutter_carplay/lib/carplay_worker.dart @@ -0,0 +1,455 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_carplay/controllers/carplay_controller.dart'; +import 'package:flutter_carplay/flutter_carplay.dart'; + +/// An object in order to integrate Apple CarPlay in navigation and +/// manage all user interface elements appearing on your screens displayed on +/// the CarPlay screen. +/// +/// Using CarPlay, you can display content from your app on a customized user interface +/// that is generated and hosted by the system itself. Control over UI elements, such as +/// touch target size, font size and color, highlights, and so on. +/// +/// **Useful Links:** +/// - [What is CarPlay?](https://developer.apple.com/carplay/) +/// - [Request CarPlay Framework](https://developer.apple.com/contact/carplay/) +/// - [Learn more about MFi Program](https://mfi.apple.com) +class FlutterCarplay { + /// A main Flutter CarPlay Controller to manage the system. + static final FlutterCarPlayController _carPlayController = + FlutterCarPlayController(); + + /// CarPlay main bridge as a listener from CarPlay and native side. + StreamSubscription? _eventBroadcast; + + /// Current CarPlay and mobile app connection status. + static String _connectionStatus = ConnectionStatusTypes.unknown.name; + + /// The size (in logical pixels, square) used when rasterizing Flutter asset + /// SVGs referenced by image fields (e.g. `CPListItem.image`, + /// `CPGridButton.image`, `CPPoi.image`) before they are sent to the native + /// side. Defaults to [defaultSvgRasterSize] (120). + static int svgRasterSize = defaultSvgRasterSize; + + /// A listener function, which will be triggered when CarPlay connection changes + /// and will be transmitted to the main code, allowing the user to access + /// the current connection status. + Function(ConnectionStatusTypes status)? _onCarplayConnectionChange; + + /// Creates an [FlutterCarplay] and starts the connection. + FlutterCarplay() { + if (defaultTargetPlatform != TargetPlatform.iOS) return; + + _eventBroadcast = _carPlayController.eventChannel + .receiveBroadcastStream() + .listen((event) async { + final FCPChannelTypes receivedChannelType = + EnumUtils.enumFromString(FCPChannelTypes.values, event['type']); + switch (receivedChannelType) { + case FCPChannelTypes.onCarplayConnectionChange: + final ConnectionStatusTypes connectionStatus = + EnumUtils.enumFromString( + ConnectionStatusTypes.values, + event['data']['status'], + ); + _connectionStatus = connectionStatus.name; + if (_onCarplayConnectionChange != null) { + _onCarplayConnectionChange!(connectionStatus); + } + break; + case FCPChannelTypes.onFCPListItemSelected: + await _carPlayController.processFCPListItemSelectedChannel( + event['data']['elementId'], + ); + break; + case FCPChannelTypes.onFCPListImageRowItemSelected: + await _carPlayController.processFCPListImageRowItemSelectedChannel( + event['data']['elementId'], + ); + break; + case FCPChannelTypes.onFCPListImageRowItemElementSelected: + _carPlayController.processFCPListImageRowItemElementSelectedChannel( + event['data']['elementId'], + event['data']['index'], + ); + break; + case FCPChannelTypes.onFCPAlertActionPressed: + _carPlayController.processFCPAlertActionPressed( + event['data']['elementId'], + ); + break; + case FCPChannelTypes.onPresentStateChanged: + _carPlayController.processFCPAlertTemplateCompleted( + event['data']['completed'], + ); + break; + case FCPChannelTypes.onGridButtonPressed: + _carPlayController.processFCPGridButtonPressed( + event['data']['elementId'], + ); + break; + case FCPChannelTypes.onBarButtonPressed: + _carPlayController.processFCPBarButtonPressed( + event['data']['elementId'], + ); + break; + case FCPChannelTypes.onTextButtonPressed: + _carPlayController.processFCPTextButtonPressed( + event['data']['elementId'], + ); + break; + case FCPChannelTypes.onSearchTextUpdated: + _carPlayController.processFCPSearchTextUpdated( + event['data']['elementId'], + event['data']['searchText'], + ); + break; + case FCPChannelTypes.onSearchResultSelected: + _carPlayController.processFCPSearchResultSelected( + event['data']['elementId'], + event['data']['itemElementId'], + ); + break; + case FCPChannelTypes.onSearchButtonPressed: + _carPlayController.processFCPSearchButtonPressed( + event['data']['elementId'], + ); + break; + case FCPChannelTypes.onScreenBackButtonPressed: + final String elementId = event['data']['elementId']; + final CPTemplate? poppedTemplate = FlutterCarPlayController + .templateHistory + .where((item) => item.uniqueId == elementId) + .firstOrNull; + poppedTemplate?.onPop?.call(); + FlutterCarPlayController.templateHistory + .removeWhere((item) => item.uniqueId == elementId); + break; + default: + break; + } + }); + } + + /// A function that will disconnect all event listeners from CarPlay. The action + /// will be irrevocable, and a new [FlutterCarplay] controller must be created after this, + /// otherwise CarPlay will be unusable. + /// + /// [!] It is not recommended to use this function if you do not know what you are doing. + void closeConnection() { + _eventBroadcast?.cancel(); + } + + /// A function that will resume the paused all event listeners from CarPlay. + void resumeConnection() { + _eventBroadcast?.resume(); + } + + /// A function that will pause the all active event listeners from CarPlay. + void pauseConnection() { + _eventBroadcast?.pause(); + } + + /// Callback function will be fired when CarPlay connection status is changed. + /// For example, when CarPlay is connected to the device, in the background state, + /// or completely disconnected. + /// + /// See also: [ConnectionStatusTypes] + void addListenerOnConnectionChange( + Function(ConnectionStatusTypes status) onCarplayConnectionChange, + ) { + _onCarplayConnectionChange = onCarplayConnectionChange; + } + + /// Removes the callback function that has been set before in order to listen + /// on CarPlay connection status changed. + void removeListenerOnConnectionChange() { + _onCarplayConnectionChange = null; + } + + /// Current CarPlay connection status. It will return one of [ConnectionStatusTypes] as String. + static String get connectionStatus { + return _connectionStatus; + } + + /// Sets the root template of the navigation hierarchy. If a navigation + /// hierarchy already exists, CarPlay replaces the entire hierarchy. + /// + /// - rootTemplate is a template to use as the root of a new navigation hierarchy. If one exists, + /// it will replace the current rootTemplate. **Must be one of the type:** + /// [CPTabBarTemplate], [CPGridTemplate], [CPListTemplate], + /// [CPInformationTemplate], [CPPointOfInterestTemplate], [CPSearchTemplate] + /// If not, it will throw an [TypeError] + /// + /// - If animated is true, CarPlay animates the presentation of the template, but will be ignored + /// this flag when there isn’t an existing navigation hierarchy to replace. + /// + /// [!] CarPlay cannot have more than 5 templates on one screen. + static Future setRootTemplate({ + required CPTemplate rootTemplate, + bool animated = true, + }) async { + if (rootTemplate is CPTabBarTemplate || + rootTemplate is CPGridTemplate || + rootTemplate is CPListTemplate || + rootTemplate is CPInformationTemplate || + rootTemplate is CPPointOfInterestTemplate || + rootTemplate is CPSearchTemplate) { + return FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.setRootTemplate, { + 'rootTemplate': rootTemplate.toJson(), + 'animated': animated, + }).then((value) { + if (value == true) { + if (FlutterCarPlayController.templateHistory.isEmpty) { + FlutterCarPlayController.templateHistory.add(rootTemplate); + } else { + FlutterCarPlayController.templateHistory[0] = rootTemplate; + } + } + }); + } else { + throw TypeError(); + } + } + + /// It will set the current root template again. + Future forceUpdateRootTemplate() { + return FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.forceUpdateRootTemplate, + ); + } + + /// It will update the sections of the [CPListTemplate] which has the given [elementId]. + Future updateListTemplateSections({ + required String elementId, + required List sections, + }) async { + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.updateListTemplateSections, + { + 'elementId': elementId, + 'sections': + sections.map((CPListSection section) => section.toJson()).toList(), + }, + ); + + if (isCompleted == true) { + final template = + FlutterCarPlayController.getTemplateFromHistory( + elementId); + template?.updateSections(sections); + } + return; + } + + /// It will update the information items of the [CPInformationTemplate] which has the given [elementId]. + Future updateInformationTemplateItems({ + required String elementId, + required List items, + }) async { + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.updateInformationTemplateItems, + { + 'elementId': elementId, + 'items': items.map((CPInformationItem item) => item.toJson()).toList(), + }, + ); + + if (isCompleted == true) { + final template = FlutterCarPlayController.getTemplateFromHistory< + CPInformationTemplate>(elementId); + template?.updateInformationItems(items); + } + return; + } + + /// It will update the actions of the [CPInformationTemplate] which has the given [elementId]. + Future updateInformationTemplateActions({ + required String elementId, + required List actions, + }) async { + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.updateInformationTemplateActions, + { + 'elementId': elementId, + 'actions': + actions.map((CPTextButton action) => action.toJson()).toList(), + }, + ); + + if (isCompleted == true) { + final template = FlutterCarPlayController.getTemplateFromHistory< + CPInformationTemplate>(elementId); + template?.updateActions(actions); + } + return; + } + + /// It will update the templates of the [CPTabBarTemplate] which has the given [elementId]. + /// Supported template types: [CPListTemplate], [CPPointOfInterestTemplate], + /// [CPGridTemplate], [CPInformationTemplate] + Future updateTabBarTemplates({ + required String elementId, + required List templates, + }) async { + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.updateTabBarTemplates, + { + 'elementId': elementId, + 'templates': + templates.map((CPTemplate template) => template.toJson()).toList(), + }, + ); + + if (isCompleted == true) { + final template = + FlutterCarPlayController.getTemplateFromHistory( + elementId); + template?.updateTemplates(templates); + } + return; + } + + /// Getter for current root template. + /// Return one of type [CPTabBarTemplate], [CPGridTemplate], [CPListTemplate] + static dynamic get rootTemplate { + return FlutterCarPlayController.currentRootTemplate; + } + + /// It will present [CPAlertTemplate] modally. + /// + /// - template is to present modally. + /// - If animated is true, CarPlay animates the presentation of the template. + /// + /// [!] CarPlay can only present one modal template at a time. + static Future showAlert({ + required CPAlertTemplate template, + bool animated = true, + }) { + return FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.setAlert, + { + 'rootTemplate': template.toJson(), + 'animated': animated, + 'onPresent': template.onPresent != null ? true : false, + }, + ).then((value) { + if (value == true) { + FlutterCarPlayController.currentPresentTemplate = template; + } + }); + } + + /// It will present [CPActionSheetTemplate] modally. + /// + /// - template is to present modally. + /// - If animated is true, CarPlay animates the presentation of the template. + /// + /// [!] CarPlay can only present one modal template at a time. + static Future showActionSheet({ + required CPActionSheetTemplate template, + bool animated = true, + }) { + return FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.setActionSheet, + { + 'rootTemplate': template.toJson(), + 'animated': animated, + }, + ).then((value) { + if (value == true) { + FlutterCarPlayController.currentPresentTemplate = template; + } + }); + } + + /// Removes the top-most template from the navigation hierarchy. + /// + /// - If animated is true, CarPlay animates the transition between templates. + /// - count represents how many times this function will occur. + static Future pop({bool animated = true, int count = 1}) async { + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.popTemplate, + {'count': count, 'animated': animated}, + ); + + return isCompleted ?? false; + } + + /// Removes all of the templates from the navigation hierarchy except the root template. + /// If animated is true, CarPlay animates the presentation of the template. + static Future popToRoot({bool animated = true}) async { + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.popToRootTemplate, + animated, + ); + + return isCompleted ?? false; + } + + /// Removes a modal template. Since [CPAlertTemplate] and [CPActionSheetTemplate] are both + /// modals, they can be removed. If animated is true, CarPlay animates the transition between templates. + static Future popModal({bool animated = true}) async { + FlutterCarPlayController.currentPresentTemplate = null; + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.closePresent, + animated, + ); + + return isCompleted ?? false; + } + + /// Adds a template to the navigation hierarchy and displays it. + /// + /// - template is to add to the navigation hierarchy. **Must be one of the type:** + /// [CPGridTemplate] or [CPListTemplate] [CPInformationTemplate] [CPPointOfInterestTemplate] If not, it will throw an [TypeError] + /// + /// - If animated is true, CarPlay animates the transition between templates. + static Future push({ + required CPTemplate template, + bool animated = true, + }) async { + if (template is CPGridTemplate || + template is CPListTemplate || + template is CPInformationTemplate || + template is CPPointOfInterestTemplate || + template is CPSearchTemplate) { + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.pushTemplate, + { + 'template': template.toJson(), + 'animated': animated, + }, + ); + if (isCompleted == true) { + _carPlayController.addTemplateToHistory(template); + } + return isCompleted ?? false; + } else { + throw TypeError(); + } + } + + /// Navigate to the shared instance of the NowPlaying Template + /// + /// - If animated is true, CarPlay animates the transition between templates. + static Future showSharedNowPlaying({bool animated = true}) async { + final bool? isCompleted = + await FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.showNowPlaying, + animated, + ); + return isCompleted ?? false; + } +} diff --git a/third_party/flutter_carplay/lib/constants/all.dart b/third_party/flutter_carplay/lib/constants/all.dart new file mode 100644 index 00000000..50da5bad --- /dev/null +++ b/third_party/flutter_carplay/lib/constants/all.dart @@ -0,0 +1,2 @@ +export 'constants.dart'; +export 'private_constants.dart'; diff --git a/third_party/flutter_carplay/lib/constants/constants.dart b/third_party/flutter_carplay/lib/constants/constants.dart new file mode 100644 index 00000000..621507e7 --- /dev/null +++ b/third_party/flutter_carplay/lib/constants/constants.dart @@ -0,0 +1 @@ +enum ConnectionStatusTypes { connected, background, disconnected, unknown } diff --git a/third_party/flutter_carplay/lib/constants/private_constants.dart b/third_party/flutter_carplay/lib/constants/private_constants.dart new file mode 100644 index 00000000..e9ac87f0 --- /dev/null +++ b/third_party/flutter_carplay/lib/constants/private_constants.dart @@ -0,0 +1,67 @@ +enum FCPChannelTypes { + onCarplayConnectionChange, + setRootTemplate, + forceUpdateRootTemplate, + updateListItem, + updateListImageRowItem, + updateListImageRowItemElement, + onFCPListItemSelected, + onFCPListItemSelectedComplete, + onFCPListImageRowItemSelected, + onFCPListImageRowItemSelectedComplete, + onFCPListImageRowItemElementSelected, + onFCPListImageRowItemElementSelectedComplete, + onFCPAlertActionPressed, + setAlert, + onPresentStateChanged, + popTemplate, + closePresent, + pushTemplate, + showNowPlaying, + onGridButtonPressed, + setActionSheet, + onBarButtonPressed, + onTextButtonPressed, + popToRootTemplate, + onScreenBackButtonPressed, + updateTabBarTemplates, + updateListTemplateSections, + updateInformationTemplateItems, + updateInformationTemplateActions, + getMaximumNumberOfGridImages, + getMaximumSectionCount, + getMaximumItemCount, + onSearchTextUpdated, + onSearchResultSelected, + onSearchButtonPressed, + updateSearchResults, + onSearchResultSelectedComplete, +} + +enum FAAChannelTypes { + onAndroidAutoConnectionChange, + setRootTemplate, + forceUpdateRootTemplate, + pushTemplate, + popTemplate, + popToRootTemplate, + updateListTemplateSections, + updatePaneTemplate, + onListItemSelected, + onListItemSelectedComplete, + onListSectionSelected, + onToggleCheckedChange, + onPaneActionPressed, + onMapActionPressed, + onScreenBackButtonPressed, + setAlert, + closePresent, + onAlertActionPressed, + onPresentStateChanged, + updateTabBarTemplates, + onTabBarItemSelected, + onGridButtonPressed, + onGridButtonSelectedComplete, + updateMessageTemplate, + updateLongMessageTemplate, +} diff --git a/third_party/flutter_carplay/lib/controllers/all.dart b/third_party/flutter_carplay/lib/controllers/all.dart new file mode 100644 index 00000000..f2c8998b --- /dev/null +++ b/third_party/flutter_carplay/lib/controllers/all.dart @@ -0,0 +1,2 @@ +export 'android_auto_controller.dart'; +export 'carplay_controller.dart'; diff --git a/third_party/flutter_carplay/lib/controllers/android_auto_controller.dart b/third_party/flutter_carplay/lib/controllers/android_auto_controller.dart new file mode 100644 index 00000000..f00ddad9 --- /dev/null +++ b/third_party/flutter_carplay/lib/controllers/android_auto_controller.dart @@ -0,0 +1,194 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_carplay/constants/private_constants.dart'; + +import '../aa_models/alert/alert_action.dart'; +import '../aa_models/alert/alert_template.dart'; +import '../aa_models/grid/grid_button.dart'; +import '../aa_models/list/list_item.dart'; +import '../aa_models/list/list_section.dart'; +import '../aa_models/list/list_template.dart'; +import '../aa_models/map/map_action.dart'; +import '../aa_models/pane/pane_action.dart'; +import '../aa_models/template.dart'; +import '../android_auto_worker.dart'; +import '../helpers/auto_android_helper.dart'; +import '../helpers/svg_rasterizer.dart'; + +/// [FlutterAndroidAutoController] is a root object used to control and +/// communicate with Android Auto native functions. +class FlutterAndroidAutoController { + static final FlutterAutoAndroidHelper _androidAutoHelper = + const FlutterAutoAndroidHelper(); + static final MethodChannel _methodChannel = MethodChannel( + _androidAutoHelper.makeFAAChannelId(), + ); + static final EventChannel _eventChannel = EventChannel( + _androidAutoHelper.makeFAAChannelId(event: '/event'), + ); + + /// [AATabBarTemplate], [AAGridTemplate], [AAListTemplate], [AAPaneTemplate], + /// [AAMessageTemplate], and [AALongMessageTemplate] in a list. + static List templateHistory = []; + + static AATemplate? get currentRootTemplate => templateHistory.firstOrNull; + + /// The currently presented modal, i.e. [AAAlertTemplate]. + static AATemplate? currentPresentTemplate; + + MethodChannel get methodChannel => _methodChannel; + + EventChannel get eventChannel => _eventChannel; + + Future flutterToNativeModule( + FAAChannelTypes type, [ + dynamic data, + ]) async { + return FlutterAndroidAutoController.flutterToNativeModuleStatic(type, data); + } + + static Future flutterToNativeModuleStatic( + FAAChannelTypes type, [ + dynamic data, + ]) async { + await resolveSvgInPayload(data, size: FlutterAndroidAuto.svgRasterSize); + final bool? value = await _methodChannel.invokeMethod( + type.name, + data, + ); + return value; + } + + static Future updateAAListTemplateSections({ + required String elementId, + required List sections, + }) async { + final payload = { + 'elementId': elementId, + 'sections': + sections.map((AAListSection section) => section.toJson()).toList(), + }; + + final bool? isCompleted = await flutterToNativeModuleStatic( + FAAChannelTypes.updateListTemplateSections, + payload, + ); + + if (isCompleted == true) { + for (final template in templateHistory) { + if (template is AAListTemplate && template.uniqueId == elementId) { + template.updateSections(sections); + return; + } + } + } + } + + Future processFAAListItemSelectedChannel(String elementId) async { + final AAListItem? item = _androidAutoHelper.findAAListItem( + templates: templateHistory, + elementId: elementId, + ); + if (item == null) return; + + Future complete() async { + await flutterToNativeModule( + FAAChannelTypes.onListItemSelectedComplete, + item.uniqueId, + ); + } + + try { + await Future.sync(() => item.onPress?.call(complete, item)); + } catch (_) { + await complete(); + } + } + + Future processFAAGridButtonPressed(String elementId) async { + final AAGridButton? item = _androidAutoHelper.findAAGridButton( + templates: templateHistory, + elementId: elementId, + ); + if (item == null) return; + + Future complete() async { + await flutterToNativeModule( + FAAChannelTypes.onGridButtonSelectedComplete, + item.uniqueId, + ); + } + + try { + await Future.sync(() => item.onPress?.call(complete, item)); + } catch (_) { + await complete(); + } + } + + void processFAAListSectionSelectedChannel( + String elementId, + int selectedIndex, + ) { + final AAListSection? listSection = _androidAutoHelper.findAAListSection( + templates: templateHistory, + elementId: elementId, + ); + final selectedItem = listSection?.items.elementAtOrNull(selectedIndex); + + if (listSection != null && selectedItem != null) { + listSection.selectedIndex = selectedIndex; + listSection.onSelected?.call(selectedIndex, selectedItem); + } + } + + void processFAAToggleCheckedChangeChannel(String elementId, bool checked) { + final AAListItem? listItem = _androidAutoHelper.findAAListItem( + templates: templateHistory, + elementId: elementId, + ); + + final toggle = listItem?.toggle; + if (toggle != null) { + toggle.isChecked = checked; + toggle.onCheckedChange?.call(checked, listItem!); + } + } + + void processFAAPaneActionPressedChannel(String elementId) { + final AAPaneAction? paneAction = _androidAutoHelper.findAAPaneAction( + templates: templateHistory, + elementId: elementId, + ); + paneAction?.onPress?.call(); + } + + void processFAAMapActionPressedChannel(String elementId) { + final AAMapAction? action = _androidAutoHelper.findAAMapAction( + templates: templateHistory, + elementId: elementId, + ); + action?.onPress?.call(); + } + + void processFAAAlertActionPressed(String elementId) { + final template = currentPresentTemplate; + if (template is! AAAlertTemplate) return; + + final AAAlertAction? action = + template.actions.cast().firstWhere( + (action) => action?.uniqueId == elementId, + orElse: () => null, + ); + action?.onPress(); + } + + void processFAAPresentStateChanged(String elementId, bool completed) { + final template = currentPresentTemplate; + if (template is AAAlertTemplate && template.onPresent != null) { + template.onPresent!(completed); + } + if (!completed) currentPresentTemplate = null; + } +} diff --git a/third_party/flutter_carplay/lib/controllers/carplay_controller.dart b/third_party/flutter_carplay/lib/controllers/carplay_controller.dart new file mode 100644 index 00000000..feefbd9a --- /dev/null +++ b/third_party/flutter_carplay/lib/controllers/carplay_controller.dart @@ -0,0 +1,443 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_carplay/flutter_carplay.dart'; + +/// [FlutterCarPlayController] is an root object in order to control and communication +/// system with the Apple CarPlay and native functions. +class FlutterCarPlayController { + static final FlutterCarplayHelper _carplayHelper = + const FlutterCarplayHelper(); + static final MethodChannel _methodChannel = MethodChannel( + _carplayHelper.makeFCPChannelId(), + ); + static final EventChannel _eventChannel = EventChannel( + _carplayHelper.makeFCPChannelId(event: '/event'), + ); + + /// [CPTabBarTemplate], [CPGridTemplate], [CPListTemplate], [CPIInformationTemplate], [CPPointOfInterestTemplate] in a List + static List templateHistory = []; + + /// [CPTabBarTemplate], [CPGridTemplate], [CPListTemplate], [CPIInformationTemplate], [CPPointOfInterestTemplate] + static CPTemplate? get currentRootTemplate => templateHistory.firstOrNull; + + /// [CPAlertTemplate], [CPActionSheetTemplate] + static CPTemplate? currentPresentTemplate; + + MethodChannel get methodChannel { + return _methodChannel; + } + + EventChannel get eventChannel { + return _eventChannel; + } + + static Future flutterToNativeModule( + FCPChannelTypes type, [ + dynamic data, + ]) async { + // Rasterize any Flutter asset SVGs referenced by image fields into PNG + // bytes before sending the payload to the native side, which cannot render + // SVG directly. Non-collection payloads pass through unchanged. + await resolveSvgInPayload(data, size: FlutterCarplay.svgRasterSize); + + final value = await _methodChannel.invokeMethod( + type.name, + data, + ); + return value; + } + + static void updateCPListItem( + CPListItem updatedListItem, + ) { + flutterToNativeModule( + FCPChannelTypes.updateListItem, + updatedListItem.toJson(), + ).then( + (value) { + if (value != true) return; + + for (var h in templateHistory) { + switch (h) { + case CPTabBarTemplate _: + for (var t in h.templates) { + if (t is CPListTemplate) { + for (var s in t.sections) { + for (var i in s.items) { + if (i.uniqueId == updatedListItem.uniqueId && + i is CPListItem) { + s.items[s.items.indexOf(i)] = updatedListItem; + return; + } + } + } + } + } + break; + case CPListTemplate _: + for (var s in h.sections) { + for (var i in s.items) { + if (i.uniqueId == updatedListItem.uniqueId && + i is CPListItem) { + s.items[s.items.indexOf(i)] = updatedListItem; + return; + } + } + } + break; + default: + } + } + }, + ); + } + + static void updateCPListImageRowItemElement( + CPListImageRowItemElement updatedListImageRowItemElement, + ) { + flutterToNativeModule( + FCPChannelTypes.updateListImageRowItemElement, + updatedListImageRowItemElement.toJson(), + ).then( + (value) { + if (value != true) return; + + for (var h in templateHistory) { + switch (h) { + case CPTabBarTemplate _: + for (var t in h.templates) { + if (t is CPListTemplate) { + for (var s in t.sections) { + for (var i in s.items) { + if (i is CPListImageRowItem) { + for (var e in i.elements ?? []) { + if (e.uniqueId == + updatedListImageRowItemElement.uniqueId) { + i.elements![i.elements!.indexOf(e)] = + updatedListImageRowItemElement; + return; + } + } + } + } + } + } + } + break; + case CPListTemplate _: + for (var s in h.sections) { + for (var i in s.items) { + if (i is CPListImageRowItem) { + for (var e in i.elements ?? []) { + if (e.uniqueId == + updatedListImageRowItemElement.uniqueId) { + i.elements![i.elements!.indexOf(e)] = + updatedListImageRowItemElement; + return; + } + } + } + } + } + break; + default: + } + } + }, + ); + } + + static void updateCPListImageRowItem( + CPListImageRowItem updatedListImageItem, + ) { + flutterToNativeModule( + FCPChannelTypes.updateListImageRowItem, + updatedListImageItem.toJson(), + ).then( + (value) { + if (value != true) return; + + for (var h in templateHistory) { + switch (h) { + case CPTabBarTemplate _: + for (var t in h.templates) { + if (t is CPListTemplate) { + for (var s in t.sections) { + for (var i in s.items) { + if (i.uniqueId == updatedListImageItem.uniqueId && + i is CPListImageRowItem) { + s.items[s.items.indexOf(i)] = updatedListImageItem; + return; + } + } + } + } + } + break; + case CPListTemplate _: + for (var s in h.sections) { + for (var i in s.items) { + if (i.uniqueId == updatedListImageItem.uniqueId && + i is CPListImageRowItem) { + s.items[s.items.indexOf(i)] = updatedListImageItem; + return; + } + } + } + break; + default: + } + } + }, + ); + } + + static Future getMaximumNumberOfGridImages() async { + final value = await _methodChannel.invokeMethod( + FCPChannelTypes.getMaximumNumberOfGridImages.name, + ); + return value; + } + + static Future getMaximumSectionCount() async { + final value = await _methodChannel.invokeMethod( + FCPChannelTypes.getMaximumSectionCount.name, + ); + return value; + } + + static Future getMaximumItemCount() async { + final value = await _methodChannel.invokeMethod( + FCPChannelTypes.getMaximumItemCount.name, + ); + return value; + } + + void addTemplateToHistory(CPTemplate template) { + if (template is CPTabBarTemplate || + template is CPGridTemplate || + template is CPInformationTemplate || + template is CPPointOfInterestTemplate || + template is CPListTemplate || + template is CPSearchTemplate) { + templateHistory.add(template); + } else { + throw TypeError(); + } + } + + Future processFCPListItemSelectedChannel(String elementId) async { + final item = _carplayHelper.findCPListTemplateItem( + templates: templateHistory, + elementId: elementId, + ); + if (item is! CPListItem) return; + + Future complete() async { + await flutterToNativeModule( + FCPChannelTypes.onFCPListItemSelectedComplete, item.uniqueId); + } + + try { + await Future.sync(() => item.onPress?.call(complete, item)); + } catch (_) { + await complete(); + } + } + + Future processFCPListImageRowItemSelectedChannel( + String elementId) async { + final item = _carplayHelper.findCPListTemplateItem( + templates: templateHistory, + elementId: elementId, + ); + + if (item is! CPListImageRowItem) return; + + Future complete() async { + await flutterToNativeModule( + FCPChannelTypes.onFCPListImageRowItemSelectedComplete, item.uniqueId); + } + + try { + await Future.sync(() => item.onPress?.call(complete, item)); + } catch (_) { + await complete(); + } + } + + Future processFCPListImageRowItemElementSelectedChannel( + String elementId, + int index, + ) async { + final item = _carplayHelper.findCPListTemplateItem( + templates: templateHistory, + elementId: elementId, + ); + + if (item is! CPListImageRowItem) return; + + Future complete() async { + await flutterToNativeModule( + FCPChannelTypes.onFCPListImageRowItemElementSelectedComplete, + item.uniqueId, + ); + } + + try { + await Future.sync(() => item.onItemPress?.call(complete, item, index)); + } catch (_) { + await complete(); + } + } + + void processFCPAlertActionPressed(String elementId) { + if (currentPresentTemplate is! CPActionsTemplate) return; + + final actions = (currentPresentTemplate as CPActionsTemplate).actions; + for (var action in actions) { + if (action.uniqueId == elementId) { + action.onPress(); + return; + } + } + } + + void processFCPAlertTemplateCompleted(bool completed) { + if (currentPresentTemplate is CPAlertTemplate) { + (currentPresentTemplate as CPAlertTemplate).onPresent?.call(completed); + } + } + + void processFCPGridButtonPressed(String elementId) { + CPGridButton? gridButton; + l1: + for (var t in templateHistory) { + if (t is CPGridTemplate) { + for (var b in t.buttons) { + if (b.uniqueId == elementId) { + gridButton = b; + break l1; + } + } + } + } + gridButton?.onPress?.call(); + } + + void processFCPBarButtonPressed(String elementId) { + for (var t in templateHistory) { + final List listTemplates = []; + if (t is CPTabBarTemplate) { + for (var template in t.templates) { + if (template is CPListTemplate) listTemplates.add(template); + } + } else if (t is CPListTemplate) { + listTemplates.add(t); + } + for (var list in listTemplates) { + if (list.backButton?.uniqueId == elementId) { + list.backButton?.onPress(); + return; + } + } + } + } + + void processFCPTextButtonPressed(String elementId) { + for (var t in templateHistory) { + if (t is CPPointOfInterestTemplate) { + for (CPPointOfInterest p in t.poi) { + if (p.primaryButton != null && + p.primaryButton!.uniqueId == elementId) { + p.primaryButton!.onPress(); + return; + } + if (p.secondaryButton != null && + p.secondaryButton!.uniqueId == elementId) { + p.secondaryButton!.onPress(); + return; + } + } + } else { + if (t is CPInformationTemplate) { + for (CPTextButton b in t.actions) { + if (b.uniqueId == elementId) { + b.onPress(); + return; + } + } + } + } + } + } + + void processFCPSearchTextUpdated(String elementId, String searchText) { + for (var t in templateHistory) { + if (t is CPSearchTemplate && t.uniqueId == elementId) { + t.onUpdatedSearchText?.call( + searchText, + (List results) { + t.updateResults(results); + final items = results.map((e) => e.toJson()).toList(); + FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.updateSearchResults, + { + 'elementId': elementId, + 'searchResults': items, + }, + ); + }, + ); + return; + } + } + } + + void processFCPSearchResultSelected(String elementId, String itemElementId) { + for (var t in templateHistory) { + if (t is CPSearchTemplate && t.uniqueId == elementId) { + CPListItem? selectedItem; + for (var item in t.currentResults) { + if (item.uniqueId == itemElementId) { + selectedItem = item; + break; + } + } + if (selectedItem != null) { + t.onSelectedResult?.call( + selectedItem, + () { + FlutterCarPlayController.flutterToNativeModule( + FCPChannelTypes.onSearchResultSelectedComplete, + {'elementId': elementId}, + ); + }, + ); + } + return; + } + } + } + + void processFCPSearchButtonPressed(String elementId) { + for (var t in templateHistory) { + if (t is CPSearchTemplate && t.uniqueId == elementId) { + t.onSearchTemplateSearchButtonPressed?.call(); + return; + } + } + } + + static T? getTemplateFromHistory(String elementId) { + for (final template in templateHistory) { + if (template is T && template.uniqueId == elementId) return template; + + if (template is CPTabBarTemplate) { + for (final t in template.templates) { + if (t is T && t.uniqueId == elementId) return t; + } + } + } + return null; + } +} diff --git a/third_party/flutter_carplay/lib/flutter_carplay.dart b/third_party/flutter_carplay/lib/flutter_carplay.dart new file mode 100644 index 00000000..aea62785 --- /dev/null +++ b/third_party/flutter_carplay/lib/flutter_carplay.dart @@ -0,0 +1,16 @@ +// Android Auto & CarPlay Workers +export 'package:flutter_carplay/android_auto_worker.dart' + show FlutterAndroidAuto; +export 'package:flutter_carplay/carplay_worker.dart' show FlutterCarplay; + +// Constants +export 'package:flutter_carplay/constants/all.dart'; + +// Helpers +export 'package:flutter_carplay/helpers/all.dart'; + +// Models +export 'package:flutter_carplay/models/all.dart'; + +// Android Auto Models +export 'package:flutter_carplay/aa_models/all.dart'; diff --git a/third_party/flutter_carplay/lib/helpers/all.dart b/third_party/flutter_carplay/lib/helpers/all.dart new file mode 100644 index 00000000..ba2f0ba9 --- /dev/null +++ b/third_party/flutter_carplay/lib/helpers/all.dart @@ -0,0 +1,4 @@ +export 'auto_android_helper.dart'; +export 'carplay_helper.dart'; +export 'enum_utils.dart'; +export 'svg_rasterizer.dart'; diff --git a/third_party/flutter_carplay/lib/helpers/auto_android_helper.dart b/third_party/flutter_carplay/lib/helpers/auto_android_helper.dart new file mode 100644 index 00000000..bb1ee07d --- /dev/null +++ b/third_party/flutter_carplay/lib/helpers/auto_android_helper.dart @@ -0,0 +1,103 @@ +import 'package:flutter_carplay/flutter_carplay.dart'; + +class FlutterAutoAndroidHelper { + const FlutterAutoAndroidHelper(); + + AAListItem? findAAListItem({ + required List templates, + required String elementId, + }) { + for (final template in templates) { + for (final listTemplate in _listTemplates(template)) { + for (final section in listTemplate.sections) { + for (final item in section.items) { + if (item.uniqueId == elementId) return item; + } + } + } + } + return null; + } + + AAListSection? findAAListSection({ + required List templates, + required String elementId, + }) { + for (final template in templates) { + for (final listTemplate in _listTemplates(template)) { + for (final section in listTemplate.sections) { + if (section.uniqueId == elementId) return section; + } + } + } + return null; + } + + AAGridButton? findAAGridButton({ + required List templates, + required String elementId, + }) { + for (final template in templates) { + for (final gridTemplate in _gridTemplates(template)) { + for (final button in gridTemplate.buttons) { + if (button.uniqueId == elementId) return button; + } + } + } + return null; + } + + AAPaneAction? findAAPaneAction({ + required List templates, + required String elementId, + }) { + for (final template in templates) { + if (template is AAPaneTemplate) { + for (final action in template.actions) { + if (action.uniqueId == elementId) return action; + } + } + } + return null; + } + + /// DELTA D: the map action strip's actions live on the map template itself, + /// not on the content template nested inside it, so the pane lookup above + /// cannot find them. + AAMapAction? findAAMapAction({ + required List templates, + required String elementId, + }) { + for (final template in templates) { + if (template is AAMapWithContentTemplate) { + for (final action in template.mapActions) { + if (action.uniqueId == elementId) return action; + } + } + } + return null; + } + + Iterable _listTemplates(AATemplate template) sync* { + if (template is AAListTemplate) { + yield template; + } else if (template is AATabBarTemplate) { + for (final tab in template.tabs) { + if (tab is AAListTemplate) yield tab; + } + } + } + + Iterable _gridTemplates(AATemplate template) sync* { + if (template is AAGridTemplate) { + yield template; + } else if (template is AATabBarTemplate) { + for (final tab in template.tabs) { + if (tab is AAGridTemplate) yield tab; + } + } + } + + String makeFAAChannelId({String event = ''}) => + 'com.oguzhnatly.flutter_android_auto$event'; +} diff --git a/third_party/flutter_carplay/lib/helpers/carplay_helper.dart b/third_party/flutter_carplay/lib/helpers/carplay_helper.dart new file mode 100644 index 00000000..8bee09e9 --- /dev/null +++ b/third_party/flutter_carplay/lib/helpers/carplay_helper.dart @@ -0,0 +1,41 @@ +import 'package:flutter_carplay/flutter_carplay.dart'; + +class FlutterCarplayHelper { + const FlutterCarplayHelper(); + + CPListTemplateItem? findCPListTemplateItem({ + required List templates, + required String elementId, + }) { + CPListTemplateItem? listItem; + l1: + for (var t in templates) { + final List listTemplates = []; + if (t is CPTabBarTemplate) { + for (var template in t.templates) { + if (template is CPListTemplate) { + listTemplates.add(template); + } + } + } else if (t is CPListTemplate) { + listTemplates.add(t); + } + if (listTemplates.isNotEmpty) { + for (var list in listTemplates) { + for (var section in list.sections) { + for (var item in section.items) { + if (item.uniqueId == elementId) { + listItem = item; + break l1; + } + } + } + } + } + } + return listItem; + } + + String makeFCPChannelId({String? event = ''}) => + 'com.oguzhnatly.flutter_carplay${event!}'; +} diff --git a/third_party/flutter_carplay/lib/helpers/enum_utils.dart b/third_party/flutter_carplay/lib/helpers/enum_utils.dart new file mode 100644 index 00000000..70572073 --- /dev/null +++ b/third_party/flutter_carplay/lib/helpers/enum_utils.dart @@ -0,0 +1,9 @@ +class EnumUtils { + const EnumUtils._(); + + static T enumFromString(Iterable values, String string) { + return values.firstWhere( + (T f) => f.name.toUpperCase() == string.toUpperCase(), + ); + } +} diff --git a/third_party/flutter_carplay/lib/helpers/svg_rasterizer.dart b/third_party/flutter_carplay/lib/helpers/svg_rasterizer.dart new file mode 100644 index 00000000..6091747b --- /dev/null +++ b/third_party/flutter_carplay/lib/helpers/svg_rasterizer.dart @@ -0,0 +1,245 @@ +import 'dart:convert'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:flutter_svg/flutter_svg.dart'; + +/// Default raster size (in logical pixels, square) used when an SVG asset is +/// rasterized to PNG bytes for native consumption. +/// +/// This can be overridden globally via [FlutterCarplay.svgRasterSize] / +/// [FlutterAndroidAuto.svgRasterSize], which are forwarded to +/// [resolveSvgInPayload]. +const defaultSvgRasterSize = 120; + +/// In-memory cache of rasterized SVG assets keyed by `assetPath|size`. +final _svgRasterCache = {}; + +/// In-flight rasterization operations keyed by `assetPath|size`. Concurrent +/// requests for the same asset/size share a single operation instead of +/// rasterizing the same SVG multiple times. +final _svgRasterInflight = >{}; + +/// Clears the in-memory rasterized SVG cache. +/// +/// Primarily intended for tests, but safe to call at any time. +@visibleForTesting +void clearSvgRasterCache() => _svgRasterCache.clear(); + +/// Returns `true` when [value] points to a Flutter asset SVG. +/// +/// Two conditions must hold: the value ends with `.svg` (case-insensitive) and +/// it is not a remote (`http`/`https`) URL. +bool isSvgAsset(String? value) { + final lower = value?.trim().toLowerCase(); + if (lower == null) return false; + return lower.endsWith('.svg') && !lower.startsWith('http'); +} + +/// Rasterizes the Flutter asset SVG at [assetPath] into PNG bytes. +/// +/// The output is a square image of [size] x [size] logical pixels. Results are +/// cached in-memory keyed by `assetPath|size`, so repeated calls return the +/// same [Uint8List] instance. Concurrent calls for the same asset/size share a +/// single in-flight operation. +/// +/// Returns `null` when the asset cannot be loaded or rasterized (e.g. invalid +/// SVG, missing asset). +Future rasterizeSvgAsset( + String assetPath, { + int size = defaultSvgRasterSize, +}) { + final cacheKey = '$assetPath|$size'; + + final cached = _svgRasterCache[cacheKey]; + if (cached != null) return Future.value(cached); + + final inflight = _svgRasterInflight[cacheKey]; + if (inflight != null) return inflight; + + final operation = _rasterize(assetPath, size, cacheKey); + _svgRasterInflight[cacheKey] = operation; + return operation.whenComplete(() => _svgRasterInflight.remove(cacheKey)); +} + +/// Performs the actual rasterization for [rasterizeSvgAsset]. +Future _rasterize( + String assetPath, + int size, + String cacheKey, +) async { + try { + // Load the asset bytes ourselves (rather than via [SvgAssetLoader]) so that + // a missing asset surfaces here as a catchable error instead of going + // through flutter_svg's asset cache. + final assetData = await rootBundle.load(assetPath); + final svgString = utf8.decode(assetData.buffer.asUint8List()); + + final loader = SvgStringLoader(svgString); + final pictureInfo = await vg.loadPicture(loader, null); + + try { + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + + final pictureSize = pictureInfo.size; + final sourceWidth = pictureSize.width.isFinite && pictureSize.width > 0 + ? pictureSize.width + : size.toDouble(); + final sourceHeight = pictureSize.height.isFinite && pictureSize.height > 0 + ? pictureSize.height + : size.toDouble(); + + // Scale uniformly to fit within the target square while preserving the + // aspect ratio, then center within the [size] x [size] canvas. + final scale = (size / sourceWidth) < (size / sourceHeight) + ? size / sourceWidth + : size / sourceHeight; + final dx = (size - sourceWidth * scale) / 2; + final dy = (size - sourceHeight * scale) / 2; + + canvas.translate(dx, dy); + canvas.scale(scale); + canvas.drawPicture(pictureInfo.picture); + + final rendered = recorder.endRecording(); + final image = await rendered.toImage(size, size); + try { + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) return null; + final bytes = byteData.buffer.asUint8List(); + _svgRasterCache[cacheKey] = bytes; + return bytes; + } finally { + image.dispose(); + rendered.dispose(); + } + } finally { + pictureInfo.picture.dispose(); + } + } catch (error, stackTrace) { + debugPrint('flutter_carplay: failed to rasterize SVG "$assetPath": $error'); + debugPrintStack(stackTrace: stackTrace); + return null; + } +} + +/// Every payload key that may reference a Flutter asset SVG, paired with how the +/// rasterized bytes are attached. +/// +/// This is the single source of truth for the walker. When a model gains a new +/// image-bearing `toJson()` key, add it here. The coverage test +/// (`test/helpers/svg_rasterizer_coverage_test.dart`) asserts that every model +/// image key is represented here so new keys cannot silently slip through. +/// +/// Models emitting these keys: +/// - `image` -> CPListItem, CPGridButton, CPPointOfInterest, and all +/// CPListImageRowItem*Element subtypes. Bytes are attached +/// under `imageData`. +/// - `imageUrl` -> AAListItem (Android Auto). The native contract expects the +/// bytes under `imageData`. +/// - `accessoryImage` / `trailingImage` -> CPListItem trailing/accessory image. +/// Bytes are attached under `trailingImageData`. +/// - `gridImages`-> CPListImageRowItem (legacy iOS grid images); the native +/// contract expects the bytes under `gridImageData`. +@visibleForTesting +const svgImageDataKeys = { + 'image': 'imageData', + 'imageUrl': 'imageData', + 'accessoryImage': 'trailingImageData', + 'trailingImage': 'trailingImageData', + 'gridImages': 'gridImageData', +}; + +/// Image keys whose values are lists rather than a single asset string. +@visibleForTesting +const svgListImageKeys = {'gridImages'}; + +/// Keys that look image-related but must never be rasterized. +/// +/// - `systemIcon` -> CPTemplate tab image (resolved natively as an SF Symbol / +/// tab image, not an asset SVG we rasterize). +/// - `imageTitles` -> CPListImageRowItem labels (text, not images). +/// - `imageTint`/`trailingImageTint`/`gridImageTints` -> tint metadata, not +/// image references. +@visibleForTesting +const svgIgnoredKeys = { + 'systemIcon', + 'imageTitles', + 'imageTint', + 'trailingImageTint', + 'gridImageTints', +}; + +/// The sibling keys under which the walker attaches rasterized bytes (e.g. +/// `imageData`, `gridImageData`). These hold raw byte payloads, so the walker +/// must never recurse into them. +final _svgDataKeys = svgImageDataKeys.values.toSet(); + +/// Recursively walks a method-channel [node] (maps/lists), rasterizing any +/// Flutter asset SVG referenced by an image-bearing key (see [svgImageDataKeys]) +/// and attaching the PNG bytes to a sibling `Data` key. +/// +/// Behavior: +/// - A single-image key (e.g. `image`, `imageUrl`) whose value is an SVG asset +/// -> sibling `Data` ([Uint8List]). +/// - A list-image key (e.g. `gridImages`) -> sibling `Data` +/// (`List`, `null` for non-SVG entries). +/// - Keys in [svgIgnoredKeys] are skipped entirely (never rasterized, never +/// recursed into). +/// - Original image strings are preserved for native fallback / back-compat. +/// +/// The [node] is mutated in place and also returned for convenience. +Future resolveSvgInPayload( + dynamic node, { + int size = defaultSvgRasterSize, +}) async { + if (node is Map) { + for (final entry in svgImageDataKeys.entries) { + final value = node[entry.key]; + if (svgListImageKeys.contains(entry.key)) { + if (value is! List) continue; + var hasSvg = false; + final data = []; + for (final item in value) { + final bytes = await _rasterizeIfSvg(item, size); + data.add(bytes); + if (bytes != null) hasSvg = true; + } + if (hasSvg) node[entry.value] = data; + } else { + final bytes = await _rasterizeIfSvg(value, size); + if (bytes != null) node[entry.value] = bytes; + } + } + + // Recurse into all values, skipping ignored keys and the byte payloads we + // just attached. The latter are raster bytes ([Uint8List], which is itself + // a `List`) or lists of them; descending into those would needlessly + // walk every individual byte. + for (final key in node.keys.toList()) { + if (svgIgnoredKeys.contains(key)) continue; + if (_svgDataKeys.contains(key)) continue; + final value = node[key]; + if (value is Uint8List) continue; + await resolveSvgInPayload(value, size: size); + } + } else if (node is List) { + for (final item in node) { + if (item is Uint8List) continue; + await resolveSvgInPayload(item, size: size); + } + } + + return node; +} + +/// Returns rasterized PNG bytes when [value] is a Flutter asset SVG string, +/// otherwise `null`. +Future _rasterizeIfSvg(dynamic value, int size) { + if (value is String && isSvgAsset(value)) { + return rasterizeSvgAsset(value, size: size); + } + return Future.value(); +} diff --git a/third_party/flutter_carplay/lib/models/action_sheet/action_sheet_template.dart b/third_party/flutter_carplay/lib/models/action_sheet/action_sheet_template.dart new file mode 100644 index 00000000..d7d4ec06 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/action_sheet/action_sheet_template.dart @@ -0,0 +1,53 @@ +import 'package:flutter_carplay/models/alert/alert_action.dart'; +import 'package:uuid/uuid.dart'; + +import '../template.dart'; + +/// A template that displays a modal action sheet. +/// https://developer.apple.com/documentation/carplay/cpactionsheettemplate +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPActionSheetTemplate extends CPTemplate implements CPActionsTemplate { + /// Unique id of the object. + final String _elementId; + + /// The title of the action sheet. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final String? title; + + /// The descriptive message providing details about the reason for displaying the action sheet. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final String? message; + + /// The list of actions available on the action sheet. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + @override + final List actions; + + /// Creates [CPActionSheetTemplate] + CPActionSheetTemplate({ + this.title, + this.message, + required this.actions, + super.tabTitle, + super.showsTabBadge = false, + super.systemIcon, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'message': message, + 'actions': actions.map((e) => e.toJson()).toList(), + 'tabTitle': tabTitle, + 'showsTabBadge': showsTabBadge, + 'systemIcon': systemIcon, + 'runtimeType': 'FCPActionSheetTemplate', + }; + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/action_sheet/all.dart b/third_party/flutter_carplay/lib/models/action_sheet/all.dart new file mode 100644 index 00000000..5236d18e --- /dev/null +++ b/third_party/flutter_carplay/lib/models/action_sheet/all.dart @@ -0,0 +1 @@ +export 'action_sheet_template.dart'; diff --git a/third_party/flutter_carplay/lib/models/alert/alert_action.dart b/third_party/flutter_carplay/lib/models/alert/alert_action.dart new file mode 100644 index 00000000..a30df547 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/alert/alert_action.dart @@ -0,0 +1,42 @@ +import 'package:flutter_carplay/models/alert/alert_constants.dart'; +import 'package:uuid/uuid.dart'; + +/// An object that encapsulates an action the user can perform on an action sheet or alert. +/// https://developer.apple.com/documentation/carplay/cpalertaction +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPAlertAction { + /// Unique id of the object. + final String _elementId; + + /// The action button’s title. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final String title; + + /// The display style for the action button. + /// Default is [CPAlertActionStyle.normal] + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final CPAlertActionStyle style; + + /// The closure that CarPlay invokes after the user taps the action button. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final Function() onPress; + + /// Creates [CPAlertAction] + CPAlertAction({ + required this.title, + this.style = CPAlertActionStyle.normal, + required this.onPress, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'style': style.name, + 'runtimeType': 'FCPAlertAction', + }; + + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/alert/alert_constants.dart b/third_party/flutter_carplay/lib/models/alert/alert_constants.dart new file mode 100644 index 00000000..ea50a304 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/alert/alert_constants.dart @@ -0,0 +1,3 @@ +/// Display styles for an alert’s action button. +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +enum CPAlertActionStyle { normal, cancel, destructive } diff --git a/third_party/flutter_carplay/lib/models/alert/alert_template.dart b/third_party/flutter_carplay/lib/models/alert/alert_template.dart new file mode 100644 index 00000000..2401ca7d --- /dev/null +++ b/third_party/flutter_carplay/lib/models/alert/alert_template.dart @@ -0,0 +1,55 @@ +import 'package:flutter_carplay/models/alert/alert_action.dart'; +import 'package:uuid/uuid.dart'; + +import '../template.dart'; + +/// A template that displays a modal alert. +/// https://developer.apple.com/documentation/carplay/cpalerttemplate +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPAlertTemplate extends CPTemplate implements CPActionsTemplate { + /// Unique id of the object. + final String _elementId; + + /// The array of title variants. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final List titleVariants; + + /// The array of actions available on the alert. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + @override + final List actions; + + /// The closure that CarPlay invokes after the user taps the action button. + /// Notes: + /// - If completed is true, the alert successfully presented. If not, you may want to show an error to the user. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final Function(bool completed)? onPresent; + + /// Creates [CPAlertTemplate] + CPAlertTemplate({ + required this.titleVariants, + required this.actions, + this.onPresent, + super.tabTitle, + super.showsTabBadge = false, + super.systemIcon, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'titleVariants': titleVariants, + 'actions': actions.map((e) => e.toJson()).toList(), + 'onPresent': onPresent != null ? true : false, + 'tabTitle': tabTitle, + 'showsTabBadge': showsTabBadge, + 'systemIcon': systemIcon, + 'runtimeType': 'FCPAlertTemplate', + }; + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/alert/all.dart b/third_party/flutter_carplay/lib/models/alert/all.dart new file mode 100644 index 00000000..5f9cd4eb --- /dev/null +++ b/third_party/flutter_carplay/lib/models/alert/all.dart @@ -0,0 +1,3 @@ +export 'alert_action.dart'; +export 'alert_constants.dart'; +export 'alert_template.dart'; diff --git a/third_party/flutter_carplay/lib/models/all.dart b/third_party/flutter_carplay/lib/models/all.dart new file mode 100644 index 00000000..2ad9241f --- /dev/null +++ b/third_party/flutter_carplay/lib/models/all.dart @@ -0,0 +1,11 @@ +export 'action_sheet/all.dart'; +export 'alert/all.dart'; +export 'button/all.dart'; +export 'common/all.dart'; +export 'grid/all.dart'; +export 'information/all.dart'; +export 'list/all.dart'; +export 'poi/all.dart'; +export 'search/all.dart'; +export 'tabbar/all.dart'; +export 'template.dart'; diff --git a/third_party/flutter_carplay/lib/models/button/all.dart b/third_party/flutter_carplay/lib/models/button/all.dart new file mode 100644 index 00000000..2081529b --- /dev/null +++ b/third_party/flutter_carplay/lib/models/button/all.dart @@ -0,0 +1,3 @@ +export 'bar_button.dart'; +export 'button_constants.dart'; +export 'text_button.dart'; diff --git a/third_party/flutter_carplay/lib/models/button/bar_button.dart b/third_party/flutter_carplay/lib/models/button/bar_button.dart new file mode 100644 index 00000000..f3397645 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/button/bar_button.dart @@ -0,0 +1,43 @@ +import 'package:uuid/uuid.dart'; + +import 'button_constants.dart'; + +/// A button for placement in a navigation bar. +/// https://developer.apple.com/documentation/carplay/cpbarbutton +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPBarButton { + /// Unique id of the object. + final String _elementId; + + /// The title displayed on the bar button. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final String title; + + /// The style to use when displaying the button. + /// Default is [CPBarButtonStyle.rounded] + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final CPBarButtonStyle buttonStyle; + + /// A block that CarPlay calls when the user taps a bar button. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final Function() onPress; + + /// Creates [CPBarButton] + CPBarButton({ + required this.title, + this.buttonStyle = CPBarButtonStyle.rounded, + required this.onPress, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'buttonStyle': buttonStyle.name, + 'runtimeType': 'FCPBarButton', + }; + + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/button/button_constants.dart b/third_party/flutter_carplay/lib/models/button/button_constants.dart new file mode 100644 index 00000000..794068d4 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/button/button_constants.dart @@ -0,0 +1,5 @@ +/// The display style of a bar button. +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +enum CPBarButtonStyle { none, rounded } + +enum CPTextButtonStyle { normal, cancel, confirm } diff --git a/third_party/flutter_carplay/lib/models/button/text_button.dart b/third_party/flutter_carplay/lib/models/button/text_button.dart new file mode 100644 index 00000000..6b3fa979 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/button/text_button.dart @@ -0,0 +1,43 @@ +import 'package:uuid/uuid.dart'; + +import 'button_constants.dart'; + +/// A button that displays a stylized title. +/// https://developer.apple.com/documentation/carplay/CPTextButton +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPTextButton { + /// Unique id of the object. + final String _elementId; + + /// The text the button displays. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final String title; + + /// The text style the button applies to its title. + /// Default is [CPTextButtonStyle.normal] + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final CPTextButtonStyle textstyle; + + /// A closure that CarPlay invokes when the user taps the button. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final Function() onPress; + + /// Creates [CPTextButton] + CPTextButton({ + required this.title, + this.textstyle = CPTextButtonStyle.normal, + required this.onPress, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'textstyle': textstyle.name, + 'runtimeType': 'FCPTextButton', + }; + + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/common/all.dart b/third_party/flutter_carplay/lib/models/common/all.dart new file mode 100644 index 00000000..e463f94f --- /dev/null +++ b/third_party/flutter_carplay/lib/models/common/all.dart @@ -0,0 +1,2 @@ +export 'image_tint.dart'; +export 'ui_color.dart'; diff --git a/third_party/flutter_carplay/lib/models/common/image_tint.dart b/third_party/flutter_carplay/lib/models/common/image_tint.dart new file mode 100644 index 00000000..8236248f --- /dev/null +++ b/third_party/flutter_carplay/lib/models/common/image_tint.dart @@ -0,0 +1,130 @@ +import 'ui_color.dart'; + +/// Host-aware tint options for image glyphs shown in CarPlay and Android Auto. +/// +/// Prefer [AutoImageTint.platform] for icons that must remain visible in focused +/// or selected rows. It lets the host choose an appropriate color on Android +/// Auto and uses a high-contrast system color on CarPlay. +/// +/// Custom colors use [UIColor] RGB channels authored as byte values from `0` to +/// `255`. Native platforms convert those byte values internally, so callers do +/// not need to account for UIKit's normalized color component range. +class AutoImageTint { + final AutoImageTintType type; + + /// Light-mode custom color. Only used by [AutoImageTint.custom]. + final UIColor? color; + + /// Dark-mode custom color. Falls back to [color] when omitted. + final UIColor? darkColor; + + /// Adds contrast protection where the platform does not manage selected-state + /// icon contrast for us. Currently this is applied by the CarPlay renderer. + final bool selectedSafe; + + const AutoImageTint._({ + required this.type, + this.color, + this.darkColor, + this.selectedSafe = true, + }); + + /// Uses the host platform's default icon tint. + const AutoImageTint.platform({bool selectedSafe = true}) + : this._( + type: AutoImageTintType.platform, + selectedSafe: selectedSafe, + ); + + /// Uses the host platform's primary tint color. + const AutoImageTint.primary({bool selectedSafe = true}) + : this._( + type: AutoImageTintType.primary, + selectedSafe: selectedSafe, + ); + + /// Uses the host platform's secondary tint color. + const AutoImageTint.secondary({bool selectedSafe = true}) + : this._( + type: AutoImageTintType.secondary, + selectedSafe: selectedSafe, + ); + + /// Uses a platform-standard red tint. + const AutoImageTint.red({bool selectedSafe = true}) + : this._( + type: AutoImageTintType.red, + selectedSafe: selectedSafe, + ); + + /// Uses a platform-standard green tint. + const AutoImageTint.green({bool selectedSafe = true}) + : this._( + type: AutoImageTintType.green, + selectedSafe: selectedSafe, + ); + + /// Uses a platform-standard blue tint. + const AutoImageTint.blue({bool selectedSafe = true}) + : this._( + type: AutoImageTintType.blue, + selectedSafe: selectedSafe, + ); + + /// Uses a platform-standard yellow tint. + const AutoImageTint.yellow({bool selectedSafe = true}) + : this._( + type: AutoImageTintType.yellow, + selectedSafe: selectedSafe, + ); + + /// Uses custom RGB byte colors for light and optional dark mode. + /// + /// Pass [color] and [darkColor] as [UIColor] values with RGB channels from + /// `0` to `255`. If [darkColor] is omitted, [color] is reused in dark mode. + const AutoImageTint.custom({ + required UIColor color, + UIColor? darkColor, + bool selectedSafe = true, + }) : this._( + type: AutoImageTintType.custom, + color: color, + darkColor: darkColor, + selectedSafe: selectedSafe, + ); + + Map toJson() => { + 'type': type.name, + 'color': color?.toJson(), + 'darkColor': darkColor?.toJson(), + 'selectedSafe': selectedSafe, + }; +} + +/// Convenience helpers for configuring [AutoImageTint]. +extension AutoImageTintDarkColor on AutoImageTint { + /// Returns a copy of this tint with [darkColor] as its dark-mode custom color. + /// + /// [darkColor] is authored with RGB byte channels from `0` to `255`. Native + /// platforms convert those values internally. For non-custom tint types this + /// method creates a custom tint, using the provided [darkColor] for both light + /// and dark mode unless the original tint already had a custom light [color]. + AutoImageTint withDarkColor(UIColor darkColor) { + return AutoImageTint.custom( + color: color ?? darkColor, + darkColor: darkColor, + selectedSafe: selectedSafe, + ); + } +} + +enum AutoImageTintType { + platform, + primary, + secondary, + red, + green, + blue, + yellow, + custom, +} diff --git a/third_party/flutter_carplay/lib/models/common/ui_color.dart b/third_party/flutter_carplay/lib/models/common/ui_color.dart new file mode 100644 index 00000000..4d0214e5 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/common/ui_color.dart @@ -0,0 +1,43 @@ +/// An object that stores color data and sometimes opacity. +/// +/// Pass RGB channels as byte values from `0` to `255`. Native platforms convert +/// those byte channels internally to the format they need, such as UIKit's +/// normalized `0.0` to `1.0` components. +/// +/// [alpha] accepts the existing normalized `0.0` to `1.0` range, and native +/// platforms also tolerate byte-style alpha values from `0` to `255`. +/// iOS 2.0+ | iPadOS 2.0+ | Mac Catalyst 13.1+ +class UIColor { + /// Red channel, authored as a byte value from `0` to `255`. + final int red; + + /// Green channel, authored as a byte value from `0` to `255`. + final int green; + + /// Blue channel, authored as a byte value from `0` to `255`. + final int blue; + + /// Opacity. Prefer `0.0` to `1.0`; native platforms also accept `0` to `255`. + final double alpha; + + /// Creates [UIColor]. + /// + /// RGB values outside `0` to `255` are clamped when serialized. + const UIColor({ + required this.red, + required this.green, + required this.blue, + this.alpha = 1.0, + }); + + Map toJson() { + return { + 'red': _clampByte(red), + 'green': _clampByte(green), + 'blue': _clampByte(blue), + 'alpha': alpha, + }; + } + + static int _clampByte(int value) => value.clamp(0, 255).toInt(); +} diff --git a/third_party/flutter_carplay/lib/models/grid/all.dart b/third_party/flutter_carplay/lib/models/grid/all.dart new file mode 100644 index 00000000..2a5672a9 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/grid/all.dart @@ -0,0 +1,2 @@ +export 'grid_button.dart'; +export 'grid_template.dart'; diff --git a/third_party/flutter_carplay/lib/models/grid/grid_button.dart b/third_party/flutter_carplay/lib/models/grid/grid_button.dart new file mode 100644 index 00000000..1bfaab60 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/grid/grid_button.dart @@ -0,0 +1,62 @@ +import 'package:uuid/uuid.dart'; + +import '../common/image_tint.dart'; + +/// A menu item button displayed on a grid template. +/// https://developer.apple.com/documentation/carplay/cpgridbutton +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPGridButton { + /// Unique id of the object. + final String _elementId; + + /// An array of title variants for the button. + /// When the system displays the button, it selects the title that best fits the available + /// screen space, so arrange the titles from most to least preferred when creating a grid button. + /// Also, localize each title for display to the user, and **be sure to include at least + /// one title in the array.** + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final List titleVariants; + + /// The image displayed on the button. + /// + /// Supports these formats: + /// - **Asset path**: `images/flutter_logo.png` (from pubspec.yaml assets) + /// - **SVG asset**: `images/icon.svg` (rasterized to PNG before being sent to + /// the native side; remote/`file://` SVGs are not supported) + /// - **File path**: `file:///path/to/image.png` (local file on device) + /// - **Network URL**: `https://example.com/image.png` (remote image) + /// + /// **[!] When creating a grid button, do NOT provide an animated image. If you do, the button + /// uses the first image in the animation sequence.** + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final String image; + + /// Optional tint applied to [image]. + final AutoImageTint? imageTint; + + /// The block invoked after the user taps the button. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final Function()? onPress; + + /// Creates [CPGridButton] + CPGridButton({ + required this.titleVariants, + required this.image, + this.imageTint, + this.onPress, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + Map toJson() => { + '_elementId': _elementId, + 'titleVariants': titleVariants, + 'image': image, + 'imageTint': imageTint?.toJson(), + 'onPress': onPress != null ? true : false, + 'runtimeType': 'FCPGridButton', + }; + + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/grid/grid_template.dart b/third_party/flutter_carplay/lib/models/grid/grid_template.dart new file mode 100644 index 00000000..fd04838e --- /dev/null +++ b/third_party/flutter_carplay/lib/models/grid/grid_template.dart @@ -0,0 +1,50 @@ +import 'package:flutter_carplay/models/grid/grid_button.dart'; +import 'package:uuid/uuid.dart'; + +import '../template.dart'; + +/// Creates a grid template with a title and a set of buttons. +/// https://developer.apple.com/documentation/carplay/cpgridtemplate +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPGridTemplate extends CPTemplate { + /// Unique id of the object. + final String _elementId; + + /// The title shown in the grid template’s navigation bar. + /// [systemIcon] must be set in order for the title to be displayed in a tab bar. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final String title; + + /// The array of grid buttons displayed on the template. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final List buttons; + + /// Creates [CPGridTemplate] in order to display a grid of items as buttons. + /// When creating the grid template, provide an array of [CPGridButton] objects. + /// Each button must contain a title that is shown in the grid template's navigation bar. + CPGridTemplate({ + required this.title, + required this.buttons, + super.tabTitle, + super.showsTabBadge = false, + super.systemIcon, + super.onPop, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'buttons': buttons.map((e) => e.toJson()).toList(), + 'tabTitle': tabTitle, + 'showsTabBadge': showsTabBadge, + 'systemIcon': systemIcon, + 'runtimeType': 'FCPGridTemplate', + }; + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/information/all.dart b/third_party/flutter_carplay/lib/models/information/all.dart new file mode 100644 index 00000000..525bcfab --- /dev/null +++ b/third_party/flutter_carplay/lib/models/information/all.dart @@ -0,0 +1,3 @@ +export 'information_constants.dart'; +export 'information_item.dart'; +export 'information_template.dart'; diff --git a/third_party/flutter_carplay/lib/models/information/information_constants.dart b/third_party/flutter_carplay/lib/models/information/information_constants.dart new file mode 100644 index 00000000..aa5e4f27 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/information/information_constants.dart @@ -0,0 +1,3 @@ +/// The layout that an information template uses to arrange its items. +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +enum CPInformationTemplateLayout { leading, twoColumn } diff --git a/third_party/flutter_carplay/lib/models/information/information_item.dart b/third_party/flutter_carplay/lib/models/information/information_item.dart new file mode 100644 index 00000000..da13fcaf --- /dev/null +++ b/third_party/flutter_carplay/lib/models/information/information_item.dart @@ -0,0 +1,35 @@ +import 'package:uuid/uuid.dart'; + +/// A data object that provides content for an information template. +/// https://developer.apple.com/documentation/carplay/cpinformationitem +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +class CPInformationItem { + /// Unique id of the object. + final String _elementId; + + /// The text that the template displays as the item’s title. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final String? title; + + /// The text that the template displays below or beside the item’s title. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final String? detail; + + /// Creates [CPInformationItem] + CPInformationItem({ + this.title, + this.detail, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'detail': detail, + 'runtimeType': 'FCPInformationItem', + }; + + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/information/information_template.dart b/third_party/flutter_carplay/lib/models/information/information_template.dart new file mode 100644 index 00000000..ebcc3830 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/information/information_template.dart @@ -0,0 +1,75 @@ +import 'package:uuid/uuid.dart'; + +import '../button/text_button.dart'; +import '../template.dart'; +import 'information_constants.dart'; +import 'information_item.dart'; + +/// A template object that displays and manages information items and text buttons. +/// https://developer.apple.com/documentation/carplay/cpinformationtemplate +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +class CPInformationTemplate extends CPTemplate { + /// Unique id of the object. + final String _elementId; + + /// The template’s title. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final String title; + + /// The layout that the template uses to arrange its items. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final CPInformationTemplateLayout layout; + + /// The actions that the template displays. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final List actions; + + /// An array of information items that the template displays. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final List informationItems; + + /// Creates [CPInformationTemplate] + CPInformationTemplate({ + required this.title, + required this.layout, + required this.actions, + required this.informationItems, + super.tabTitle, + super.showsTabBadge = false, + super.systemIcon, + super.onPop, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'layout': layout.name, + 'title': title, + 'actions': actions.map((e) => e.toJson()).toList(), + 'informationItems': informationItems.map((e) => e.toJson()).toList(), + 'tabTitle': tabTitle, + 'showsTabBadge': showsTabBadge, + 'systemIcon': systemIcon, + 'runtimeType': 'FCPInformationTemplate', + }; + + @override + String get uniqueId { + return _elementId; + } + + void updateInformationItems(List newItems) { + final copy = List.from(newItems); + informationItems + ..clear() + ..addAll(copy); + } + + void updateActions(List newActions) { + final copy = List.from(newActions); + actions + ..clear() + ..addAll(copy); + } +} diff --git a/third_party/flutter_carplay/lib/models/list/all.dart b/third_party/flutter_carplay/lib/models/list/all.dart new file mode 100644 index 00000000..d2ccd781 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/all.dart @@ -0,0 +1,7 @@ +export 'list_constants.dart'; +export 'list_image_row_item.dart'; +export 'list_image_row_item/all.dart'; +export 'list_item.dart'; +export 'list_section.dart'; +export 'list_template.dart'; +export 'list_template_item.dart'; diff --git a/third_party/flutter_carplay/lib/models/list/list_constants.dart b/third_party/flutter_carplay/lib/models/list/list_constants.dart new file mode 100644 index 00000000..ef7429d6 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_constants.dart @@ -0,0 +1,7 @@ +/// The locations where a list item can display the Now Playing indicator. +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +enum CPListItemPlayingIndicatorLocation { trailing, leading } + +/// The accessory types that a list item can display. +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +enum CPListItemAccessoryType { none, cloud, disclosureIndicator } diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item.dart new file mode 100644 index 00000000..b116b1b6 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item.dart @@ -0,0 +1,103 @@ +import 'dart:async'; + +import 'package:uuid/uuid.dart'; + +import '../../controllers/carplay_controller.dart'; +import '../common/image_tint.dart'; +import 'list_image_row_item/list_image_row_item_element.dart'; +import 'list_template_item.dart'; + +/// A List template row that displays a series of images. +/// https://developer.apple.com/documentation/carplay/cplistimagerowitem +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +class CPListImageRowItem extends CPListTemplateItem { + /// Unique id of the object. + final String _elementId; + + /// The images that appear in the list item’s image row. + /// + /// Each entry accepts an asset path, an SVG Flutter asset (`.svg`, rasterized + /// to PNG before reaching the native side), a `file://` path, or a network + /// URL. Remote/`file://` SVGs are not supported. + /// iOS 14.0–26.0 | iPadOS 14.0–26.0 | Mac Catalyst 14.0–26.0 + final List? gridImages; + + /// Optional tints for [gridImages], aligned by index. + final List? gridImageTints; + + /// The titles displayed for each image in this image row item. + /// iOS 14.0–26.0 | iPadOS 14.0–26.0 | Mac Catalyst 14.0–26.0' + final List? imageTitles; + + /// The array of elements used to draw visible elements. + /// Can be one of the following types of elements: + /// - [CPListImageRowItemCardElement] + /// - [CPListImageRowItemCondensedElement] + /// - [CPListImageRowItemRowElement] + /// - [CPListImageRowItemGridElement] + /// - [CPListImageRowItemImageGridElement] + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + List? elements; + + /// A Boolean value indicating whether the elements should be visible in more than a single line. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + final bool allowsMultipleLines; + + /// An optional closure that CarPlay invokes when the user selects the list item. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + FutureOr Function(Function() complete, CPListImageRowItem self)? + onPress; + + /// An optional closure that CarPlay invokes when the user selects an image. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + FutureOr Function( + Function() complete, CPListImageRowItem self, int? index)? onItemPress; + + /// Creates [CPListImageRowItem] + CPListImageRowItem({ + super.text, + this.gridImages, + this.gridImageTints, + this.imageTitles, + this.elements, + this.allowsMultipleLines = false, + this.onPress, + this.onItemPress, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'text': text, + 'gridImages': gridImages, + 'gridImageTints': + gridImageTints?.map((tint) => tint?.toJson()).toList(), + 'imageTitles': imageTitles, + 'elements': elements?.map((e) => e.toJson()).toList(), + 'allowsMultipleLines': allowsMultipleLines, + 'onPress': onPress != null ? true : false, + 'onItemPress': onItemPress != null ? true : false, + 'runtimeType': 'FCPListImageRowItem', + }; + + void setText(String text) { + this.text = text; + FlutterCarPlayController.updateCPListImageRowItem(this); + } + + void setElements(List elements) { + this.elements = elements; + FlutterCarPlayController.updateCPListImageRowItem(this); + } + + /// The maximum number of images that an image row can contain. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 14.0+ + static Future getMaximumNumberOfGridImages() => + FlutterCarPlayController.getMaximumNumberOfGridImages(); + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item/all.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item/all.dart new file mode 100644 index 00000000..544767a9 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item/all.dart @@ -0,0 +1,7 @@ +export 'list_image_row_item_card_element.dart'; +export 'list_image_row_item_condensed_element.dart'; +export 'list_image_row_item_constants.dart'; +export 'list_image_row_item_element.dart'; +export 'list_image_row_item_grid_element.dart'; +export 'list_image_row_item_image_grid_element.dart'; +export 'list_image_row_item_row_element.dart'; diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_card_element.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_card_element.dart new file mode 100644 index 00000000..7d354399 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_card_element.dart @@ -0,0 +1,114 @@ +import 'package:uuid/uuid.dart'; + +import '../../../controllers/carplay_controller.dart'; +import '../../common/image_tint.dart'; +import '../../common/ui_color.dart'; +import 'list_image_row_item_element.dart'; + +/// https://developer.apple.com/documentation/carplay/cplistimagerowitemcardelement +/// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ +class CPListImageRowItemCardElement implements CPListImageRowItemElement { + /// Unique id of the object. + final String _elementId; + + /// The image to display in the card. + /// + /// Accepts an asset path, an SVG Flutter asset (`.svg`, rasterized to PNG + /// before reaching the native side), a `file://` path, or a network URL. + /// Remote/`file://` SVGs are not supported. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + @override + String image; + + @override + AutoImageTint? imageTint; + + /// The title associated with this element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String? title; + + /// The subtitle associated with this element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String? subtitle; + + /// A UIColor used to tint the element. When @c showsImageFullHeight is true, + /// the tint color is applied behind the labels at the bottom of the card. + /// Otherwise, this color is part of the gradient color at the bottom of the card. + UIColor? tintColor; + + /// A Boolean value indicating whether the element should be fill with the image. + /// iOS 14.0–26.0 | iPadOS 14.0–26.0 | Mac Catalyst 14.0–26.0 + final bool showsImageFullHeight; + + /// Creates [CPListImageRowItemCardElement] + CPListImageRowItemCardElement({ + required this.image, + this.imageTint, + this.title, + this.subtitle, + this.showsImageFullHeight = true, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'image': image, + 'imageTint': imageTint?.toJson(), + 'title': title, + 'subtitle': subtitle, + 'tintColor': tintColor?.toJson(), + 'showsImageFullHeight': showsImageFullHeight, + 'runtimeType': 'FCPListImageRowItemCardElement', + }; + + @override + void setImage(String image, {AutoImageTint? imageTint}) { + this.image = image; + if (imageTint != null) this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + void setImageTint(AutoImageTint? imageTint) { + this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setTitle(String title) { + this.title = title; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setSubtitle(String subtitle) { + this.subtitle = subtitle; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setTintColor(UIColor tintColor) { + this.tintColor = tintColor; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void update({ + String? image, + AutoImageTint? imageTint, + String? title, + String? subtitle, + UIColor? tintColor, + bool? showsImageFullHeight, + }) { + if (image != null) this.image = image; + if (imageTint != null) this.imageTint = imageTint; + if (title != null) this.title = title; + if (subtitle != null) this.subtitle = subtitle; + if (tintColor != null) this.tintColor = tintColor; + + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_condensed_element.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_condensed_element.dart new file mode 100644 index 00000000..b206a746 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_condensed_element.dart @@ -0,0 +1,115 @@ +import 'package:uuid/uuid.dart'; + +import '../../../controllers/carplay_controller.dart'; +import '../../common/image_tint.dart'; +import 'list_image_row_item_constants.dart'; +import 'list_image_row_item_element.dart'; + +/// https://developer.apple.com/documentation/carplay/cplistimagerowitemcondensedelement +/// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ +class CPListImageRowItemCondensedElement implements CPListImageRowItemElement { + /// Unique id of the object. + final String _elementId; + + /// The image to display in the card. + /// + /// Accepts an asset path, an SVG Flutter asset (`.svg`, rasterized to PNG + /// before reaching the native side), a `file://` path, or a network URL. + /// Remote/`file://` SVGs are not supported. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + @override + String image; + + @override + AutoImageTint? imageTint; + + /// The title associated with this element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String title; + + /// The subtitle associated with this element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String? subtitle; + + /// The name of the system symbol image to use as accessory. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String? accessorySymbolName; + + /// Shape used to draw the image of the element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + final CPListImageRowItemCondensedElementShape imageShape; + + /// Creates [CPListImageRowItemCondensedElement] + CPListImageRowItemCondensedElement({ + required this.image, + required this.title, + this.imageTint, + this.subtitle, + this.accessorySymbolName, + this.imageShape = CPListImageRowItemCondensedElementShape.roundedRectangle, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'image': image, + 'imageTint': imageTint?.toJson(), + 'title': title, + 'subtitle': subtitle, + 'accessorySymbolName': accessorySymbolName, + 'imageShape': imageShape.name, + 'runtimeType': 'FCPListImageRowItemCondensedElement', + }; + + @override + void setImage(String image, {AutoImageTint? imageTint}) { + this.image = image; + if (imageTint != null) this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + void setImageTint(AutoImageTint? imageTint) { + this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setTitle(String title) { + this.title = title; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setSubtitle(String subtitle) { + this.subtitle = subtitle; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setAccessorySymbolName(String accessorySymbolName) { + this.accessorySymbolName = accessorySymbolName; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void update({ + String? image, + AutoImageTint? imageTint, + String? title, + String? subtitle, + String? accessorySymbolName, + }) { + if (image != null) this.image = image; + if (imageTint != null) this.imageTint = imageTint; + if (title != null) this.title = title; + if (subtitle != null) this.subtitle = subtitle; + if (accessorySymbolName != null) { + this.accessorySymbolName = accessorySymbolName; + } + + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_constants.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_constants.dart new file mode 100644 index 00000000..28b80018 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_constants.dart @@ -0,0 +1,19 @@ +/// Types of shape used to draw a condensed row element. +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 14.0+ +enum CPListImageRowItemCondensedElementShape { + /// The list item will render an element with a circular image. + circular, + + /// The list item will render an element with a rounded rectangle image. + roundedRectangle, +} + +/// Types of shape used to draw a condensed row element. +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 14.0+ +enum CPListImageRowItemImageGridElementShape { + /// The list item will render an element with a circular image. + circular, + + /// The list item will render an element with a rounded rectangle image. + roundedRectangle, +} diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_element.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_element.dart new file mode 100644 index 00000000..9dd6f035 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_element.dart @@ -0,0 +1,28 @@ +import '../../common/image_tint.dart'; + +/// Abstract superclass for a a row item element object. +/// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ +/// https://developer.apple.com/documentation/carplay/cplistimagerowitemelement +abstract interface class CPListImageRowItemElement { + /// The image associated with this element. + /// + /// Accepts an asset path, an SVG Flutter asset (`.svg`, rasterized to PNG + /// before reaching the native side), a `file://` path, or a network URL. + /// Remote/`file://` SVGs are not supported. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String? get image; + + /// Optional tint applied to [image]. + AutoImageTint? get imageTint; + + Map toJson(); + + String get uniqueId; + + /// Updates the element's image. See [image] for supported formats (including + /// `.svg` Flutter assets). + void setImage(String image, {AutoImageTint? imageTint}); + + /// Updates the tint applied to [image]. Pass `null` to remove the tint. + void setImageTint(AutoImageTint? imageTint); +} diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_grid_element.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_grid_element.dart new file mode 100644 index 00000000..76701c90 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_grid_element.dart @@ -0,0 +1,67 @@ +import 'package:uuid/uuid.dart'; + +import '../../../controllers/carplay_controller.dart'; +import '../../common/image_tint.dart'; +import 'list_image_row_item_element.dart'; + +/// https://developer.apple.com/documentation/carplay/cplistimagerowitemgridelement +/// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ +class CPListImageRowItemGridElement implements CPListImageRowItemElement { + /// Unique id of the object. + final String _elementId; + + /// The image to display in the card. + /// + /// Accepts an asset path, an SVG Flutter asset (`.svg`, rasterized to PNG + /// before reaching the native side), a `file://` path, or a network URL. + /// Remote/`file://` SVGs are not supported. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + @override + String image; + + @override + AutoImageTint? imageTint; + + /// Creates [CPListImageRowItemGridElement] + CPListImageRowItemGridElement({ + required this.image, + this.imageTint, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'image': image, + 'imageTint': imageTint?.toJson(), + 'runtimeType': 'FCPListImageRowItemGridElement', + }; + + @override + void setImage(String image, {AutoImageTint? imageTint}) { + this.image = image; + if (imageTint != null) this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + void setImageTint(AutoImageTint? imageTint) { + this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void update({ + String? image, + AutoImageTint? imageTint, + }) { + if (image != null) this.image = image; + if (imageTint != null) this.imageTint = imageTint; + + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_image_grid_element.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_image_grid_element.dart new file mode 100644 index 00000000..c68754e0 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_image_grid_element.dart @@ -0,0 +1,102 @@ +import 'package:uuid/uuid.dart'; + +import '../../../controllers/carplay_controller.dart'; +import '../../common/image_tint.dart'; +import 'list_image_row_item_constants.dart'; +import 'list_image_row_item_element.dart'; + +/// https://developer.apple.com/documentation/carplay/cplistimagerowitemimagegridelement +/// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ +class CPListImageRowItemImageGridElement implements CPListImageRowItemElement { + /// Unique id of the object. + final String _elementId; + + /// The image to display in the card. + /// + /// Accepts an asset path, an SVG Flutter asset (`.svg`, rasterized to PNG + /// before reaching the native side), a `file://` path, or a network URL. + /// Remote/`file://` SVGs are not supported. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + @override + String image; + + @override + AutoImageTint? imageTint; + + /// The title associated with this element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String title; + + /// The name of the system symbol image to use as accessory. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String? accessorySymbolName; + + /// Shape used to draw the image of the element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + final CPListImageRowItemImageGridElementShape imageShape; + + /// Creates [CPListImageRowItemImageGridElement] + CPListImageRowItemImageGridElement({ + required this.image, + required this.title, + this.imageTint, + this.accessorySymbolName, + this.imageShape = CPListImageRowItemImageGridElementShape.circular, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'image': image, + 'imageTint': imageTint?.toJson(), + 'title': title, + 'accessorySymbolName': accessorySymbolName, + 'imageShape': imageShape.name, + 'runtimeType': 'FCPListImageRowItemImageGridElement', + }; + + @override + void setImage(String image, {AutoImageTint? imageTint}) { + this.image = image; + if (imageTint != null) this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + void setImageTint(AutoImageTint? imageTint) { + this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setTitle(String title) { + this.title = title; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setAccessorySymbolName(String accessorySymbolName) { + this.accessorySymbolName = accessorySymbolName; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void update({ + String? image, + AutoImageTint? imageTint, + String? title, + String? accessorySymbolName, + }) { + if (image != null) this.image = image; + if (imageTint != null) this.imageTint = imageTint; + if (title != null) this.title = title; + if (accessorySymbolName != null) { + this.accessorySymbolName = accessorySymbolName; + } + + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_row_element.dart b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_row_element.dart new file mode 100644 index 00000000..e1ee61cd --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_image_row_item/list_image_row_item_row_element.dart @@ -0,0 +1,93 @@ +import 'package:uuid/uuid.dart'; + +import '../../../controllers/carplay_controller.dart'; +import '../../common/image_tint.dart'; +import 'list_image_row_item_element.dart'; + +/// https://developer.apple.com/documentation/carplay/cplistimagerowitemrowelement +/// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ +class CPListImageRowItemRowElement implements CPListImageRowItemElement { + /// Unique id of the object. + final String _elementId; + + /// The image to display in the card. + /// + /// Accepts an asset path, an SVG Flutter asset (`.svg`, rasterized to PNG + /// before reaching the native side), a `file://` path, or a network URL. + /// Remote/`file://` SVGs are not supported. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + @override + String image; + + @override + AutoImageTint? imageTint; + + /// The title associated with this element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String? title; + + /// The subtitle associated with this element. + /// iOS 26.0+ | iPadOS 26.0+ | Mac Catalyst 26.0+ + String? subtitle; + + /// Creates [CPListImageRowItemRowElement] + CPListImageRowItemRowElement({ + required this.image, + this.imageTint, + this.title, + this.subtitle, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'image': image, + 'imageTint': imageTint?.toJson(), + 'title': title, + 'subtitle': subtitle, + 'runtimeType': 'FCPListImageRowItemRowElement', + }; + + @override + void setImage(String image, {AutoImageTint? imageTint}) { + this.image = image; + if (imageTint != null) this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + void setImageTint(AutoImageTint? imageTint) { + this.imageTint = imageTint; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setTitle(String title) { + this.title = title; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void setSubtitle(String subtitle) { + this.subtitle = subtitle; + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + void update({ + String? image, + AutoImageTint? imageTint, + String? title, + String? subtitle, + }) { + if (image != null) this.image = image; + if (imageTint != null) this.imageTint = imageTint; + if (title != null) this.title = title; + if (subtitle != null) this.subtitle = subtitle; + + FlutterCarPlayController.updateCPListImageRowItemElement(this); + } + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_item.dart b/third_party/flutter_carplay/lib/models/list/list_item.dart new file mode 100644 index 00000000..a2b3ef07 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_item.dart @@ -0,0 +1,235 @@ +import 'dart:async'; + +import 'package:flutter_carplay/controllers/carplay_controller.dart'; +import 'package:flutter_carplay/models/common/image_tint.dart'; +import 'package:flutter_carplay/models/list/list_constants.dart'; +import 'package:uuid/uuid.dart'; + +import 'list_template_item.dart'; + +/// A selectable row in a list template. +/// https://developer.apple.com/documentation/carplay/cplistitem +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPListItem extends CPListTemplateItem { + /// Unique id of the object. + final String _elementId; + + /// The list item’s secondary text. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + String? detailText; + + /// The image that the list item displays in its leading region. + /// + /// Supports these formats: + /// * Asset path: `images/flutter_logo.png` from pubspec.yaml assets + /// * SVG asset: `images/icon.svg` rasterized to PNG before native display + /// * File path: `file:///path/to/image.png` local file on device + /// * Network URL: `https://example.com/image.png` remote image + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + String? image; + + /// Optional tint applied to [image]. Use [AutoImageTint.platform] when the + /// host should choose a selected or focused row safe color. + AutoImageTint? imageTint; + + /// Backward compatible trailing accessory image. + /// + /// New code should prefer [trailingImage], which also supports SVG assets and + /// tint metadata. + String? accessoryImage; + + /// The image that the list item displays in its trailing region. + /// + /// This maps to CarPlay's `accessoryImage` and takes precedence over + /// [accessoryImage] and [accessoryType]. Use it for state indicators while + /// keeping [image] for the leading user selected icon. + String? trailingImage; + + /// Optional tint applied to [trailingImage]. + AutoImageTint? trailingImageTint; + + /// The playback progress status for the content that the list item represents. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + double? playbackProgress; + + /// A Boolean value that determines whether the list item displays its Now Playing indicator. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + bool? isPlaying; + + /// The location where the list item displays its Now Playing indicator. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + CPListItemPlayingIndicatorLocation? playingIndicatorLocation; + + /// The accessory that the list item displays in its trailing region. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + CPListItemAccessoryType? accessoryType; + + /// An optional closure that CarPlay invokes when the user selects the list item. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final FutureOr Function(Function() complete, CPListItem self)? onPress; + + /// Creates [CPListItem] that manages the content of a single row in a [CPListTemplate]. + /// CarPlay manages the layout of a list item and may adjust its layout to allow for + /// the display of auxiliary content, such as, an accessory or a Now Playing indicator. + /// A list item can display primary text, secondary text, now playing indicators as playback progress, + /// an accessory image and a trailing image. + CPListItem({ + super.text, + this.detailText, + this.onPress, + this.image, + this.imageTint, + this.accessoryImage, + this.trailingImage, + this.trailingImageTint, + this.playbackProgress, + this.isPlaying, + this.playingIndicatorLocation, + this.accessoryType, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'text': text, + 'detailText': detailText, + 'onPress': onPress != null ? true : false, + 'image': image, + 'imageTint': imageTint?.toJson(), + 'accessoryImage': accessoryImage, + 'trailingImage': trailingImage, + 'trailingImageTint': trailingImageTint?.toJson(), + 'playbackProgress': playbackProgress, + 'isPlaying': isPlaying, + 'playingIndicatorLocation': playingIndicatorLocation?.name, + 'accessoryType': accessoryType?.name, + 'runtimeType': 'FCPListItem', + }; + + /// Updating the list item's primary text. + void setText(String text) { + this.text = text; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Updating the list item's secondary text. + void setDetailText(String detailText) { + this.detailText = detailText; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Updating the image which will be displayed on the leading edge of the list item cell. + /// + /// Supports these formats: + /// * Asset path: `images/flutter_logo.png` from pubspec.yaml assets + /// * SVG asset: `images/icon.svg` rasterized to PNG before native display + /// * File path: `file:///path/to/image.png` local file on device + /// * Network URL: `https://example.com/image.png` remote image + void setImage(String image, {AutoImageTint? imageTint}) { + this.image = image; + if (imageTint != null) this.imageTint = imageTint; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Updates the tint applied to [image]. Pass `null` to remove the tint. + void setImageTint(AutoImageTint? imageTint) { + this.imageTint = imageTint; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Updating the image displayed on the trailing edge of the list item cell. + /// + /// See [trailingImage] for supported formats, including SVG Flutter assets. + void setTrailingImage(String trailingImage, {AutoImageTint? imageTint}) { + this.trailingImage = trailingImage; + if (imageTint != null) trailingImageTint = imageTint; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Updates the tint applied to [trailingImage]. Pass `null` to remove it. + void setTrailingImageTint(AutoImageTint? imageTint) { + trailingImageTint = imageTint; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Updating the image displayed in the trailing region of the list item cell. + /// + /// Supports the same asset path, file path, and network URL formats as [image]. + void setAccessoryImage(String? accessoryImage) { + this.accessoryImage = accessoryImage; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Setter for playbackProgress + /// When the given value is not between 0.0 and 1.0, throws [RangeError] + void setPlaybackProgress(double playbackProgress) { + if (playbackProgress >= 0.0 && playbackProgress <= 1.0) { + this.playbackProgress = playbackProgress; + FlutterCarPlayController.updateCPListItem(this); + } else { + throw RangeError('playbackProgress must be between 0.0 and 1.0'); + } + } + + /// Setter for isPlaying + void setIsPlaying(bool isPlaying) { + this.isPlaying = isPlaying; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Setter for playingIndicatorLocation + void setPlayingIndicatorLocation( + CPListItemPlayingIndicatorLocation playingIndicatorLocation, + ) { + this.playingIndicatorLocation = playingIndicatorLocation; + FlutterCarPlayController.updateCPListItem(this); + } + + /// Setter for accessoryType + void setAccessoryType(CPListItemAccessoryType accessoryType) { + this.accessoryType = accessoryType; + FlutterCarPlayController.updateCPListItem(this); + } + + void update({ + String? text, + String? detailText, + String? image, + AutoImageTint? imageTint, + String? accessoryImage, + String? trailingImage, + AutoImageTint? trailingImageTint, + double? playbackProgress, + bool? isPlaying, + CPListItemPlayingIndicatorLocation? playingIndicatorLocation, + CPListItemAccessoryType? accessoryType, + }) { + if (text != null) this.text = text; + if (detailText != null) this.detailText = detailText; + if (image != null) this.image = image; + if (imageTint != null) this.imageTint = imageTint; + if (accessoryImage != null) this.accessoryImage = accessoryImage; + if (trailingImage != null) this.trailingImage = trailingImage; + if (trailingImageTint != null) this.trailingImageTint = trailingImageTint; + if (playbackProgress != null) { + if (playbackProgress >= 0.0 && playbackProgress <= 1.0) { + this.playbackProgress = playbackProgress; + } else { + throw RangeError('playbackProgress must be between 0.0 and 1.0'); + } + } + if (isPlaying != null) this.isPlaying = isPlaying; + if (playingIndicatorLocation != null) { + this.playingIndicatorLocation = playingIndicatorLocation; + } + if (accessoryType != null) this.accessoryType = accessoryType; + + FlutterCarPlayController.updateCPListItem(this); + } + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_section.dart b/third_party/flutter_carplay/lib/models/list/list_section.dart new file mode 100644 index 00000000..975ea97a --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_section.dart @@ -0,0 +1,46 @@ +import 'package:uuid/uuid.dart'; + +import 'list_template_item.dart'; + +/// A container that groups your list items into sections. +/// https://developer.apple.com/documentation/carplay/cplistsection +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPListSection { + /// Unique id of the object. + final String _elementId; + + /// The section’s header text. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final String? header; + + /// The section’s index title. + /// Defaults to true. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final bool? sectionIndexEnabled; + + /// The list of items for the section. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final List items; + + /// Creates [CPListSection] that contains zero or more list items. You can configure + /// a section to display a header, which CarPlay displays on the trailing edge of the screen. + CPListSection({ + this.header, + this.sectionIndexEnabled, + required List items, + String? id, + }) : items = List.from(items), + _elementId = id ?? const Uuid().v4(); + + Map toJson() => { + '_elementId': _elementId, + 'header': header, + 'items': items.map((e) => e.toJson()).toList(), + 'sectionIndexEnabled': sectionIndexEnabled, + 'runtimeType': 'FCPListSection', + }; + + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_template.dart b/third_party/flutter_carplay/lib/models/list/list_template.dart new file mode 100644 index 00000000..7bd96715 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_template.dart @@ -0,0 +1,89 @@ +import 'package:flutter_carplay/models/button/bar_button.dart'; +import 'package:flutter_carplay/models/list/list_section.dart'; +import 'package:uuid/uuid.dart'; + +import '../../controllers/carplay_controller.dart'; +import '../template.dart'; + +/// A template that displays and manages a list of items. +/// https://developer.apple.com/documentation/carplay/CPListTemplate +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPListTemplate extends CPTemplate { + /// Unique id of the object. + final String _elementId; + + /// The title that the navigation bar displays when the template is visible + /// Notes: + /// - [systemIcon] must be set in order for the title to be displayed in a tab bar. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final String? title; + + /// The sections that the list displays. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final List sections; + + /// An array of title variants for the template’s empty view. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final List? emptyViewTitleVariants; + + /// An array of subtitle variants for the template’s empty view. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final List? emptyViewSubtitleVariants; + + /// A button to display as the Back button on the navigation bar. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final CPBarButton? backButton; + + /// Creates [CPListTemplate] to display a list of items, grouped into one or more sections. + /// Each section contains an array of list items — objects that is [CPListItem] + /// + /// **Consider that some vehicles limit the number of items that [CPListTemplate] displays.** + CPListTemplate({ + this.title, + required this.sections, + this.emptyViewTitleVariants, + this.emptyViewSubtitleVariants, + super.tabTitle, + super.showsTabBadge = false, + super.systemIcon, + super.onPop, + this.backButton, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'sections': sections.map((e) => e.toJson()).toList(), + 'emptyViewTitleVariants': emptyViewTitleVariants, + 'emptyViewSubtitleVariants': emptyViewSubtitleVariants, + 'tabTitle': tabTitle, + 'showsTabBadge': showsTabBadge, + 'systemIcon': systemIcon, + 'backButton': backButton?.toJson(), + 'runtimeType': 'FCPListTemplate', + }; + + /// The maximum number of sections that the template can display. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + static Future getMaximumSectionCount() => + FlutterCarPlayController.getMaximumSectionCount(); + + /// The maximum number of items, across all sections, that the template can display. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + static Future getMaximumItemCount() => + FlutterCarPlayController.getMaximumItemCount(); + + @override + String get uniqueId { + return _elementId; + } + + void updateSections(List newSections) { + final copy = List.from(newSections); + sections + ..clear() + ..addAll(copy); + } +} diff --git a/third_party/flutter_carplay/lib/models/list/list_template_item.dart b/third_party/flutter_carplay/lib/models/list/list_template_item.dart new file mode 100644 index 00000000..ef0cd477 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/list/list_template_item.dart @@ -0,0 +1,16 @@ +/// A description of the common properties of all list item types. +/// https://developer.apple.com/documentation/carplay/cplisttemplateitem +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +abstract class CPListTemplateItem { + CPListTemplateItem({ + this.text, + }); + + /// The item’s primary text. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + String? text; + + Map toJson(); + + String get uniqueId; +} diff --git a/third_party/flutter_carplay/lib/models/poi/all.dart b/third_party/flutter_carplay/lib/models/poi/all.dart new file mode 100644 index 00000000..adf0fb6e --- /dev/null +++ b/third_party/flutter_carplay/lib/models/poi/all.dart @@ -0,0 +1,2 @@ +export 'poi.dart'; +export 'poi_template.dart'; diff --git a/third_party/flutter_carplay/lib/models/poi/poi.dart b/third_party/flutter_carplay/lib/models/poi/poi.dart new file mode 100644 index 00000000..83956c8a --- /dev/null +++ b/third_party/flutter_carplay/lib/models/poi/poi.dart @@ -0,0 +1,102 @@ +import 'package:uuid/uuid.dart'; + +import '../common/image_tint.dart'; +import '../button/text_button.dart'; + +/// A section object of list items that appear in a list template. +/// https://developer.apple.com/documentation/carplay/cppointofinterest +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +class CPPointOfInterest { + /// Unique id of the object. + final String _elementId; + + /// The latitude of the geographical coordinate. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + double latitude; + + /// The longitude of the geographical coordinate. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + double longitude; + + /// The title that the picker displays for the item. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + String title; + + /// The subtitle that the picker displays for the item. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + String? subtitle; + + /// A brief summary that the picker displays for the item. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + String? summary; + + /// The detail card’s title. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + String? detailTitle; + + /// The detail card’s subtitle. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + String? detailSubtitle; + + /// A brief summary that the detail card displays. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + String? detailSummary; + + /// A custom image that the map annotation displays. + /// Supports these formats: + /// - **Asset path**: `images/marker.png` (from pubspec.yaml assets) + /// - **SVG asset**: `images/marker.svg` (rasterized to PNG before being sent + /// to the native side; remote/`file://` SVGs are not supported) + /// - **File path**: `file:///path/to/image.png` (local file on device) + /// - **Network URL**: `https://example.com/image.png` (remote image) + String? image; + + /// Optional tint applied to [image]. + AutoImageTint? imageTint; + + /// The detail card’s primary action button. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + CPTextButton? primaryButton; + + /// The detail card’s secondary action button. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + CPTextButton? secondaryButton; + + /// Creates [CPPointOfInterest] + CPPointOfInterest({ + required this.latitude, + required this.longitude, + required this.title, + this.subtitle, + this.summary, + this.detailTitle, + this.detailSubtitle, + this.detailSummary, + this.image, + this.imageTint, + this.primaryButton, + this.secondaryButton, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + Map toJson() => { + '_elementId': _elementId, + 'latitude': latitude, + 'longitude': longitude, + 'title': title, + 'subtitle': subtitle, + 'summary': summary, + 'detailTitle': detailTitle, + 'detailSubtitle': detailSubtitle, + 'detailSummary': detailSummary, + 'image': image, + 'imageTint': imageTint?.toJson(), + 'primaryButton': primaryButton?.toJson(), + 'secondaryButton': secondaryButton?.toJson(), + 'runtimeType': 'FCPPointOfInterest', + }; + + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/poi/poi_template.dart b/third_party/flutter_carplay/lib/models/poi/poi_template.dart new file mode 100644 index 00000000..9cb6fceb --- /dev/null +++ b/third_party/flutter_carplay/lib/models/poi/poi_template.dart @@ -0,0 +1,47 @@ +import 'package:uuid/uuid.dart'; + +import '../template.dart'; +import 'poi.dart'; + +/// A template that displays a map with selectable points of interest. +/// https://developer.apple.com/documentation/carplay/cppointofinteresttemplate +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +class CPPointOfInterestTemplate extends CPTemplate { + /// Unique id of the object. + final String _elementId; + + /// The scrollable picker’s title. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final String title; + + /// The points of interest the template displays on the map and in the scrollable picker. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final List poi; + + /// Creates [CPPointOfInterestTemplate] + CPPointOfInterestTemplate({ + required this.title, + required this.poi, + super.tabTitle, + super.showsTabBadge = false, + super.systemIcon, + super.onPop, + String? id, + }) : _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'title': title, + 'poi': poi.map((e) => e.toJson()).toList(), + 'tabTitle': tabTitle, + 'showsTabBadge': showsTabBadge, + 'systemIcon': systemIcon, + 'runtimeType': 'FCPPointOfInterestTemplate', + }; + + @override + String get uniqueId { + return _elementId; + } +} diff --git a/third_party/flutter_carplay/lib/models/search/all.dart b/third_party/flutter_carplay/lib/models/search/all.dart new file mode 100644 index 00000000..7806666b --- /dev/null +++ b/third_party/flutter_carplay/lib/models/search/all.dart @@ -0,0 +1 @@ +export 'search_template.dart'; diff --git a/third_party/flutter_carplay/lib/models/search/search_template.dart b/third_party/flutter_carplay/lib/models/search/search_template.dart new file mode 100644 index 00000000..d85b0c9b --- /dev/null +++ b/third_party/flutter_carplay/lib/models/search/search_template.dart @@ -0,0 +1,59 @@ +import 'package:uuid/uuid.dart'; + +import '../list/list_item.dart'; +import '../template.dart'; + +/// A template that provides the ability to search for a destination and see a list of search results. +/// https://developer.apple.com/documentation/carplay/cpsearchtemplate +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +class CPSearchTemplate extends CPTemplate { + /// Unique id of the object. + final String _elementId; + + /// Tells the delegate that the user updated the search criteria text. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final Function(String searchText, Function(List results) update)? + onUpdatedSearchText; + + /// Tells the delegate that the user selected an item from the search result. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final Function(CPListItem selectedItem, Function() complete)? + onSelectedResult; + + /// Tells the delegate that the user tapped the keyboard's search button. + /// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ + final Function()? onSearchTemplateSearchButtonPressed; + + final List _currentResults = []; + + /// Creates [CPSearchTemplate] to display a CarPlay search interface. + CPSearchTemplate({ + String? id, + this.onUpdatedSearchText, + this.onSelectedResult, + this.onSearchTemplateSearchButtonPressed, + }) : _elementId = id ?? const Uuid().v4(), + super(); + + @override + Map toJson() => { + 'runtimeType': 'FCPSearchTemplate', + '_elementId': _elementId, + 'onUpdatedSearchText': onUpdatedSearchText != null, + 'onSelectedResult': onSelectedResult != null, + 'onSearchTemplateSearchButtonPressed': + onSearchTemplateSearchButtonPressed != null, + }; + + @override + String get uniqueId => _elementId; + + List get currentResults => _currentResults; + + void updateResults(List results) { + final copy = List.from(results); + _currentResults + ..clear() + ..addAll(copy); + } +} diff --git a/third_party/flutter_carplay/lib/models/tabbar/all.dart b/third_party/flutter_carplay/lib/models/tabbar/all.dart new file mode 100644 index 00000000..b9ce2e6a --- /dev/null +++ b/third_party/flutter_carplay/lib/models/tabbar/all.dart @@ -0,0 +1 @@ +export 'tabbar_template.dart'; diff --git a/third_party/flutter_carplay/lib/models/tabbar/tabbar_template.dart b/third_party/flutter_carplay/lib/models/tabbar/tabbar_template.dart new file mode 100644 index 00000000..4a308985 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/tabbar/tabbar_template.dart @@ -0,0 +1,65 @@ +import 'package:flutter_carplay/models/action_sheet/action_sheet_template.dart'; +import 'package:flutter_carplay/models/alert/alert_template.dart'; +import 'package:flutter_carplay/models/grid/grid_template.dart'; +import 'package:flutter_carplay/models/information/information_template.dart'; +import 'package:flutter_carplay/models/list/list_template.dart'; +import 'package:flutter_carplay/models/poi/poi_template.dart'; +import 'package:uuid/uuid.dart'; + +import '../template.dart'; + +/// A container template that displays and manages other templates, presenting them as tabs. +/// Supported template types: [CPListTemplate], [CPPointOfInterestTemplate], +/// [CPGridTemplate], [CPInformationTemplate], [CPActionSheetTemplate], [CPAlertTemplate] +/// https://developer.apple.com/documentation/carplay/cptabbartemplate +/// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ +class CPTabBarTemplate extends CPTemplate { + /// Unique id of the object. + final String _elementId; + + /// The tab bar’s templates. + /// Supported types: [CPListTemplate], [CPPointOfInterestTemplate], + /// [CPGridTemplate], [CPInformationTemplate] + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final List templates; + + /// When creating a [CPTabBarTemplate], provide an array of templates for the tab bar to display. + /// CarPlay treats the array’s templates as root templates, each with its own + /// navigation hierarchy. When a tab bar template is the rootTemplate of your + /// app’s interface controller and you use the controller to add and remove templates, + /// CarPlay applies those changes to the selected tab’s navigation hierarchy. + /// + /// [!] You can’t add a tab bar template to an existing navigation hierarchy, + /// or present one modally. + CPTabBarTemplate({ + required List templates, + super.tabTitle, + super.showsTabBadge = false, + super.systemIcon, + super.onPop, + String? id, + }) : templates = List.from(templates), + _elementId = id ?? const Uuid().v4(); + + @override + Map toJson() => { + '_elementId': _elementId, + 'tabTitle': tabTitle, + 'templates': templates.map((e) => e.toJson()).toList(), + 'showsTabBadge': showsTabBadge, + 'systemIcon': systemIcon, + 'runtimeType': 'FCPTabBarTemplate', + }; + + @override + String get uniqueId { + return _elementId; + } + + void updateTemplates(List newTemplates) { + final copy = List.from(newTemplates); + templates + ..clear() + ..addAll(copy); + } +} diff --git a/third_party/flutter_carplay/lib/models/template.dart b/third_party/flutter_carplay/lib/models/template.dart new file mode 100644 index 00000000..9d7617f1 --- /dev/null +++ b/third_party/flutter_carplay/lib/models/template.dart @@ -0,0 +1,50 @@ +import 'package:flutter/foundation.dart'; + +import 'alert/alert_action.dart'; + +/// https://developer.apple.com/documentation/carplay/cptemplate +/// iOS 12.0+ | iPadOS 12.0+ | Mac Catalyst 13.1+ +abstract class CPTemplate { + CPTemplate({ + this.tabTitle, + this.showsTabBadge = false, + this.systemIcon, + this.onPop, + }); + + /// An indicator you use to call attention to the tab. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final bool showsTabBadge; + + /// An image that represents the content of the tab. + /// Note: + /// - This property is given to tabImage + /// - If null, template title will not be display in the tab bar. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final String? systemIcon; + + /// A short title that describes the content of the tab. + /// iOS 14.0+ | iPadOS 14.0+ | Mac Catalyst 14.0+ + final String? tabTitle; + + /// Called when this template is popped from the navigation stack. + /// + /// Fires for both user-initiated pops (CarPlay back button) and + /// programmatic pops via [FlutterCarplay.pop]. Useful for cleaning + /// up subscriptions, state listeners, or analytics events tied to + /// this template's lifetime. + /// + /// Not called for modal templates (alerts, action sheets) — those + /// have their own lifecycle hooks. + final VoidCallback? onPop; + + String get uniqueId; + + Map toJson(); +} + +abstract interface class CPActionsTemplate { + const CPActionsTemplate(); + + List get actions; +} diff --git a/third_party/flutter_carplay/pubspec.yaml b/third_party/flutter_carplay/pubspec.yaml new file mode 100644 index 00000000..53b5192d --- /dev/null +++ b/third_party/flutter_carplay/pubspec.yaml @@ -0,0 +1,35 @@ +name: flutter_carplay +description: Flutter Apps are now on Apple CarPlay and Android Auto. This package aims to make it safe to use apps made with Flutter in the car by integrating with CarPlay or Android Auto. +version: 1.6.5 +homepage: https://github.com/oguzhnatly/flutter_carplay#readme +repository: https://github.com/oguzhnatly/flutter_carplay +issue_tracker: https://github.com/oguzhnatly/flutter_carplay/issues + +environment: + sdk: ">=3.6.0 <4.0.0" + flutter: ">=1.10.0" + +dependencies: + flutter: + sdk: flutter + flutter_svg: ^2.0.10+1 + uuid: ^4.5.3 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + plugin: + platforms: + # DELTA A from upstream 1.6.5: the `ios:` entry and the whole ios/ + # directory are removed. MeshMapper uses this package for Android Auto + # only. Left in place, it would link SwiftFlutterCarplayPlugin into the + # App Store build — a CarPlay scene-delegate and entitlement surface we + # do not want and cannot ship without Apple's CarPlay entitlement. + # The Dart AA* classes are pure Dart plus a MethodChannel and still + # compile on iOS, where AndroidAutoService.isSupportedPlatform is false. + android: + package: com.oguzhnatly.flutter_android_auto + pluginClass: FlutterAndroidAutoPlugin diff --git a/third_party/maplibre_gl/CHANGELOG.md b/third_party/maplibre_gl/CHANGELOG.md new file mode 100644 index 00000000..806122f1 --- /dev/null +++ b/third_party/maplibre_gl/CHANGELOG.md @@ -0,0 +1,669 @@ +## [0.25.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.24.1...v0.25.0) - 2026-01-07 + +### Added +* Logo customization options including visibility and position settings (#b4fb174). +* Explicit annotation manager initialization with clear error handling (#668). +* iOS: Attribution support for tile and raster sources with HTML link parsing. + +### Changed +* MapLibre Android SDK upgraded from `11.13.5` to `12.3.0` (#690). + - Includes synchronous GeoJSON source updates + - Support for MLT-format vector tile sources + - Better frustum offset support + - See [MapLibre Native Android 12.3.0 release notes](https://github.com/maplibre/maplibre-native/releases/tag/android-v12.3.0) +* OkHttp updated from `4.12.0` to `5.3.2` for Node.js 24 compatibility (#676, #700). +* Kotlin updated to `2.3.0` (#697, #698). +* Android Gradle Plugin updated to `8.13.2` (#695, #674). +* Android Application Plugin updated to `8.13.2` (#696, #689). +* GitHub Actions: `actions/checkout` updated from v5 to v6 (#672, #693). +* GitHub Actions: `actions/upload-artifact` updated from v4 to v6 (#688, #694). + +### Fixed +* Min/max zoom preference on iOS (#5230fab). +* `queryRenderedFeatures` now returns all targets when supplying empty layers list on iOS, aligning behavior with Android (#680). +* iOS: Enhanced LayerPropertyConverter to handle null values and improve expression parsing (#98660dc). +* Fixed `lineDasharray` and patterns reset to null in layer properties (#2b550ed). +* Improved MapLibreMapController disposing to prevent memory leaks. +* Removed unnecessary disposing of mapController in example app (#f989797). +* Fixed `setLayerProperties` and pattern images on web and Android (#9ce52a6). + - Pattern images now correctly converted to RGBA format on web + - Fixed mismatched image size error when loading pattern images + +### Refactor +* Complete refactor of example app with new UI and improved user experience (#ac877a4). +* Refactored `cameraTargetBounds` implementation on Android and iOS for consistent behavior (#8bcd74a). + +**Full Changelog**: [v0.24.1...v0.25.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.24.1...v0.25.0) + +## [0.24.1](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.24.0...v0.24.1) + +### Fixed +* Annotation tap call callbacks twice. (#652) +* Annotation APIs: use null-aware access for manager-backed collections (symbols, lines, circles, fills) to avoid null errors before style load. (#657) +* Add methods enforce explicit manager initialization with clear exceptions when style is not loaded. (#657) +* Calling add* before style load now fails fast with a clear Exception instead of risking null dereferences or silent failures. (#657) + +### Changed +* Rollback maplibre-gl to `4.7.1` version. (#660) + +### Added +* Added `onCameraMove` callback in the controller and in MapLibreMap class. (#643) + +## [0.24.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.23.0...v0.24.0) +> **Note**: This release has breaking changes.\ +> We apologize for the quick change in 0.24.0: this version definitively stabilizes the signatures of feature interaction callbacks. + +This release restores the **feature id** and makes the `Annotation` parameter **nullable** for all feature interaction callbacks (`tap` / `drag` / `hover`).\ +This unblocks interaction with style-layer features not managed by annotation managers (i.e. added via `addLayer*` / style APIs). + +### Breaking Changes + * **Tap**: `OnFeatureInteractionCallback` → `(Point point, LatLng coordinates, String id, String layerId, Annotation? annotation)`. + +* **Drag**: `OnFeatureDragCallback` → `(Point point, LatLng origin, LatLng current, LatLng delta, String id, Annotation? annotation, DragEventType eventType)`. + +* **Hover**: `OnFeatureHoverCallback` → `Point point, LatLng coordinates, String id, Annotation? annotation, HoverEventType eventType)`. + +* **Update existing listeners**: The short‑lived 0.23.0-only signatures (without `id`) are removed. + * For unmanaged style layer features `annotation` is `null` (`unmanaged` means sources/layers you add via style APIs like `addGeoJsonSource` + `addSymbolLayer`). + * For managed annotations it is the `Annotation` object. + +### Reasoning +In 0.23.0 the move to annotation objects inadvertently dropped interaction for unmanaged style features. Reintroducing `id` (and making `annotation` nullable) normalizes all three interaction paths without creating phantom annotation wrappers. + +### Migration Example +Before (0.23.0): +``` +controller.onFeatureTapped.add((p, latLng, annotation, layerId) { + print(annotation.id); +}); +``` +After (>=0.24.0): +``` +controller.onFeatureTapped.add((p, latLng, id, layerId, annotation) { + print('feature id=$id managed=${annotation != null}'); +}); +``` + +### Refactor / Quality +* (web) Refactored `onMapClick` (degenerate bbox + interactive layer filter) to surface features inserted via style APIs (unmanaged style-layer features) in `onFeatureTapped` (previously skipped; returned now with `id`, `layerId` and `annotation = null`) (#646). +* (web) Ensure map container stretches vertically by adding `style.height = '100%'` to the registered div (prevents occasional zero-height layout issues in flexible parents) (#641) + +**Full Changelog**: [v0.23.0...v0.24.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.23.0...v0.24.0) + +## [0.23.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.22.0...v0.23.0) +> **Note**: This release has breaking changes. + +This release aligns the plugin with the latest MapLibre Native (Android 11.9.0 / iOS 6.14.0), introduces runtime style switching APIs, hover interaction callbacks, and several annotation interaction improvements. It also contains a small breaking change for feature interaction callbacks. + +A big thank you to everyone who contributed to this update! + +### Breaking Changes +* `onFeatureDrag` / `onFeatureTapped` callback signatures now provide an `Annotation annotation` object instead of an `id` parameter. Update your handlers to remove the `id` argument and use `annotation.id` (or other annotation fields) as needed. + +### Highlights +* Runtime style switching via controller (`setStyle…`) without tearing down the map (#444, #603). +* Hover interaction events (`onFeatureHover`) for richer desktop/web UX (#614). +* Improved event handling reliability (cancellation & consumption fixes) (#621, #623). +* Offline region download crash fix in example (#569) and style loaded safety checks (#563). +* Updated MapLibre Native bringing PMTiles & performance improvements (#552, #582). + +### Added / Updated +* **Feature:** added set style method on controller (#444) & support setting raw style JSON on iOS/web (#603). +* **Feature:** expose hovering events (`onFeatureHover`) (#614). +* **Update:** bump Android to 11.9.0 & iOS to 6.14.0 (#582). +* **Update:** update maplibre-native to the latest versions / PMTiles support (#552). +* **CI/Tooling:** upgrade Flutter Gradle Plugin & compatibility with Flutter 3.29.0 (#542). + +### Fixed +* iOS code generation: corrected handling of Offset / Translate / expression arrays in generated bindings (#481). +* Annotation tap consumption now respected (`annotationConsumeTapEvents`). +* Prevent calling `notifyListeners()` after controller disposal (#621). +* Web: event listener cancellation & hover handling robustness (#623). +* Example: offline region download crash (#569). +* Added style readiness checks before access (#563). + +### Refactor / Quality +* Enable and fix additional lint rules to enforce consistency (#452). + +**Full Changelog**: [v0.22.0...v0.23.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.22.0...v0.23.0) + +## [0.22.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.21.0...v0.22.0) + +### Breaking changes + +* Updated maplibre-native for iOS to v6.14.0. This mainly introduces PMTiles support. See the + [maplibre-native changelog](https://github.com/maplibre/maplibre-native/blob/main/platform/ios/CHANGELOG.md#6121) + for more information. +* Updated maplibre-native for Android to v11.9.0. This mainly introduces PMTiles support. + Flutter version packed with OpenGL ES 3.0 build for now, later we could probably switch to Vulkan. + See the [maplibre-native changelog](https://github.com/maplibre/maplibre-native/blob/main/platform/android/CHANGELOG.md#1181) + for more information. +* queryRenderedFeaturesInRect support string feature ids on web (#576). + +### Changed + +* Added `await` to all `addLayer` calls (#558). + +### Fixed + +* Fixed `Unsupported operation` error on web (#551). + +## [0.21.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.20.0...v0.21.0) + +### Added + +* added the `clearAmbientCache` functionality (#502). +* added the `contains` functionality to `LatLngBounds` (#498). +* added the possibility to set `LocationEnginePlatforms` properties for better device tracking on Android (#510). + +### Changed + +* BREAKING: `onFeatureTap` returns the `layerId` (#475). +* Changed iOS package name to support Swift Package Manager (#467). +* Move the `maplibre_gl` package to a subdirectory of the repository and add + melos to orchestrate all packages (#453). + +### Removed + +* Removed support for Dart SDKs older than `3.4.0` (`Flutter SDK 3.22.0`) (#542) + +### Fixed + +* Fixed exception when destroying mapView on Android by reordering cleanup (#459). + + +## 0.20.0 + +A lot of files/classes have been renamed and moved around in this release. +If you notice any build errors, please make sure to run `flutter clean`. + +### Breaking changes + +* All Dart enums have been migrated from mixed cases to lower camelcase + according to the `camel_case_types` lint rule. +* Move `MapLibreStyles` to the main `maplibre_gl` package. You can now use the + demo style without adding `maplibre_gl_platform_interface` as a dependency. +* Updated maplibre-native for ios to v6.5.0. This introduces the new + iOS Metal renderer and the OpenGL ES renderer now uses OpenGL ES 3.0. Only + iOS Devices with an Apple A7 GPU or later are supported onwards. See the + [maplibre-native changelog](https://github.com/maplibre/maplibre-native/blob/main/platform/ios/CHANGELOG.md#600) + for more information. +* Updated maplibre-native for android to v11.0.0. This version uses + OpenGL ES 3.0. See the + [maplibre-native changelog](https://github.com/maplibre/maplibre-native/blob/main/platform/android/CHANGELOG.md#1100) + for more information. +* Renamed the method channel to `plugins.flutter.io/maplibre_gl_*` in all + packages. +* Renamed "Maplibre" to "MapLibre" to be in line with maplibre-native + (affects for example the classes `MaplibreMap` and `MaplibreMapController`). + +### Changes + +* Added support for Swift Package Manager usage on iOS. +* Migrated main iOS plugin class from Objective-C to Swift. +* Renamed iOS plugin classes from `Mapbox` to `MapLibre`. +* Removed support for Kotlin versions older than `1.9.0` (#460). + +**Full Changelog**: +[v0.19.0+2...v0.20.0](https://github.com/maplibre/flutter-maplibre-gl/compare/v0.19.0+2...v0.20.0) + +## 0.19.0 + +This is the first version where all packages are published on pub.dev. Please +use the `maplibre_gl` package directly from pub.dev from now on. Check the +`README` documentation on how to include it in your project. + +### Changes + +* Bump min Dart SDK to 3.0.0 (this was already implicitly required by transitive + dependencies) +* Prepare all packages for publishing + to [pub.dev](https://pub.dev/packages/maplibre_gl) +* Add support for + the [Fill Extrusion layer](https://maplibre.org/maplibre-style-spec/layers/#fill-extrusion) +* Add support for + the [Heatmap layer](https://maplibre.org/maplibre-style-spec/layers/#heatmap) +* Update documentation +* (android) Bump Android `compileSdkVersion` to 34 +* (iOS) Add `iosLongClickDuration` as parameter to customize the long click + duration. +* (web) Loosen the dependency constraint of [js](https://pub.dev/packages/js) to + allow `0.6.x` and `0.7.x`. + +### Bug Fixes + +* (android) Fix support for newer gradle versions (Add support for Gradle/AGP + namespace configuration) +* (android) Fix `NullPointerException` when changing the visibility of a layer ( + layer#setVisibility) +* (android) Fix enum parsing for the `onCameraTracking` callback +* (iOS) Fix tap detection on features if the feature id is null +* (web) Fix flickering when the style takes time to load + +**Full Changelog**: +[0.18.0...v0.19.0](https://github.com/maplibre/flutter-maplibre-gl/compare/0.18.0...v0.19.0) + +## 0.18.0 + +### Breaking Change: + +Already since 0.17.0, developers do not need to adapt their Podfile for iOS apps +anymore as it was previously described in the Readme. Developers who previously +added these lines should remove them, since not removing these lines may cause a +build failure on iOS. (This change actually already landed in 0.17.0, but it may +not have been sufficiently clear that not removing these lines might break +builds). + +### Other Changes: + +* new feature: set arbitrary layer properties by @m0nac0 + in [#303](https://github.com/maplibre/flutter-maplibre-gl/pull/303) +* Update release process by @m0nac0 + in [#315](https://github.com/maplibre/flutter-maplibre-gl/pull/315) +* Add workflows for automated publishing to pub.dev by @m0nac0 + in [#328](https://github.com/maplibre/flutter-maplibre-gl/pull/328) +* Fix example app pubspec by @m0nac0 + in [#329](https://github.com/maplibre/flutter-maplibre-gl/pull/329) +* Updated location plugin version for example app by @varunlohade + in [#334](https://github.com/maplibre/flutter-maplibre-gl/pull/334) +* Housekeeping: Improve docs and update outdated references to point to MapLibre + by @m0nac0 in [#330](https://github.com/maplibre/flutter-maplibre-gl/pull/330) + +**Full Changelog**: +[0.17.0...0.18.0](https://github.com/maplibre/flutter-maplibre-gl/compare/0.17.0...0.18.0) + +## 0.17.0 + +* **Repository transfer**: The project repository was transferred to the + MapLibre GitHub organization. More information + at [#221](https://github.com/maplibre/flutter-maplibre-gl/issues/221) +* Developers do not need to adapt their Podfile for iOS apps anymore as it was + previously described in the + Readme. [#278](https://github.com/maplibre/flutter-maplibre-gl/pull/278) + +### Breaking Change: + +* `maplibre_gl/mapbox_gl.dart` was renamed to `maplibre_gl/maplibre_gl.dart`. + You can do a replace-all from `import 'package:maplibre_gl/mapbox_gl.dart';` + to `import 'package:maplibre_gl/maplibre_gl.dart';` +* `useDelayedDisposal` was removed since its now fixed + in [#259](https://github.com/maplibre/flutter-maplibre-gl/pull/259) +* `useHybridCompositionOverride` was removed since it was added in the following + fix: [#203](https://github.com/maplibre/flutter-maplibre-gl/pull/203) and we + reverted + the fix and used another approach to fix the actual issue. +* The default for `myLocationRenderMode` was changed from `COMPASS` to `NORMAL` + in [#244](https://github.com/maplibre/flutter-maplibre-gl/pull/244), since the + previous default value of `COMPASS` implicitly enables displaying the location + on iOS, which could crash apps that didn't want to display the device + location. If you want to continue to use `MyLocationRenderMode.COMPASS`, + please explicitly specify it in the constructor like this: + +```dart +@override +Widget build() { + return MapLibreMap( + myLocationRenderMode: MyLocationRenderMode.COMPASS, + // ... + ); +} +``` + +* The old api `registerWith` was removed from the MapboxMapsPlugin.java, since + there is no need for that. +* The `minSdkVersion` was bumped to at least 21 now, since the native android + sdk constraint expect that. +* Changed the minimum Dart version from sdk: `2.12.0` to `2.14.0` + in `maplibre_gl_platform_interface/pubspec.yaml`. + +### Further changes + +Note: This list only contains a subset of all contributions, notably excluding +those that e.g. only affect the GitHub Actions CI or documentation. See the link +at the end for a full changelog. + +* feat: add support for reading style json from file in ios by @TimAlber + in [#132](https://github.com/maplibre/flutter-maplibre-gl/pull/132) +* Add podspecs in correct Cocoapods layout by @kuhnroyal + in [#128](https://github.com/maplibre/flutter-maplibre-gl/pull/128) +* fix: fix the queryRenderedFeatures code on iOS by @TimAlber + in [#137](https://github.com/maplibre/flutter-maplibre-gl/pull/137) +* feat: Set layer visibility by @m0nac0 + in [#138](https://github.com/maplibre/flutter-maplibre-gl/pull/138) +* feat: add support for changing the receiver’s viewport to fit given bounds by + @TimAlber in [#133](https://github.com/maplibre/flutter-maplibre-gl/pull/133) +* Change feature JSON encoding from .ascii to .utf8 by @SunBro-Marko + in [#142](https://github.com/maplibre/flutter-maplibre-gl/pull/142) +* web: implement setCameraBounds by @m0nac0 + in [#145](https://github.com/maplibre/flutter-maplibre-gl/pull/145) +* Use offical maplibre-gl.js and add README info by @Robbendebiene + in [#163](https://github.com/maplibre/flutter-maplibre-gl/pull/163) +* android: adding tileSize to raster source by @mariusvn + in [#166](https://github.com/maplibre/flutter-maplibre-gl/pull/166) +* Readme: document git default values for codespaces by @m0nac0 + in [#170](https://github.com/maplibre/flutter-maplibre-gl/pull/170) +* query source features by @Grodien + in [#154](https://github.com/maplibre/flutter-maplibre-gl/pull/154) +* Trimming styleString to simplify the JSON detection by @mariusvn + in [#175](https://github.com/maplibre/flutter-maplibre-gl/pull/175) +* Fix getVisibleRegion method by @BartoszStasiurka + in [#179](https://github.com/maplibre/flutter-maplibre-gl/pull/179) +* Reenable textureMode which was disabled in f8b2d1 by @maxammann + in [#194](https://github.com/maplibre/flutter-maplibre-gl/pull/194) +* android: Bump MapLibre SDK to 9.6.0 & OkHttp to 4.9.3 by @mariusvn + in [#184](https://github.com/maplibre/flutter-maplibre-gl/pull/184) +* Added getSourceIds to the controller by @mariusvn + in [#197](https://github.com/maplibre/flutter-maplibre-gl/pull/197) +* Moved EventChannel creation in the downloadOfflineRegion method by @mariusvn + in [#205](https://github.com/maplibre/flutter-maplibre-gl/pull/205) +* Fix crash android dispose nullpointerdereference by @GaelleJoubert + in [#203](https://github.com/maplibre/flutter-maplibre-gl/pull/203) +* Migrate links in README, pubspec to MapLibre by @kuhnroyal + in [#224](https://github.com/maplibre/flutter-maplibre-gl/pull/224) +* Update LICENSE file by @mariusvn + in [#230](https://github.com/maplibre/flutter-maplibre-gl/pull/230) +* upgrade dependency image by @m0nac0 + in [#248](https://github.com/maplibre/flutter-maplibre-gl/pull/248) +* fix-example-app by @JulianBissekkou + in [#261](https://github.com/maplibre/flutter-maplibre-gl/pull/261) +* 162-animate-camera-on-web-fix by @JulianBissekkou + in [#254](https://github.com/maplibre/flutter-maplibre-gl/pull/254) +* 243-fix-crash-when-no-location-permission by @JulianBissekkou + in [#244](https://github.com/maplibre/flutter-maplibre-gl/pull/244) +* 182-disposal-null-ref-crash by @JulianBissekkou + in [#259](https://github.com/maplibre/flutter-maplibre-gl/pull/259) +* New android sdk version by @stefanschaller + in [#270](https://github.com/maplibre/flutter-maplibre-gl/pull/270) +* 250-change-language-fixes by @stefanschaller + in [#275](https://github.com/maplibre/flutter-maplibre-gl/pull/275) +* upgrade-ios-version by @JulianBissekkou + in [#277](https://github.com/maplibre/flutter-maplibre-gl/pull/277) +* Simplify iOS usage instructions and example podfile by @m0nac0 + in [#278](https://github.com/maplibre/flutter-maplibre-gl/pull/278) +* Add opportunity to use map in widget tests by @ManoyloK + in [#281](https://github.com/maplibre/flutter-maplibre-gl/pull/281) +* fix-layers-prod-build by @stefanschaller + in [#291](https://github.com/maplibre/flutter-maplibre-gl/pull/291) +* Fix the codespace by upgrading the docker image by @ouvreboite + in [#297](https://github.com/maplibre/flutter-maplibre-gl/pull/297) +* Add `updateImageSource`. by @CaviarChen + in [#271](https://github.com/maplibre/flutter-maplibre-gl/pull/271) +* fix "unexpected null value" error when onStyleLoadedCallback is null by + @m0nac0 in [#307](https://github.com/maplibre/flutter-maplibre-gl/pull/307) +* attributionButtonPosition for web by @ouvreboite + in [#304](https://github.com/maplibre/flutter-maplibre-gl/pull/304) + +**Full Changelog**: +https://github.com/maplibre/flutter-maplibre-gl/compare/0.16.0...0.17.0 + +## 0.16.0, Jun 28, 2022 + +* cherry-picked all commits from upstream up + to [https://github.com/flutter-mapbox-gl/maps/commit/3496907955cd4b442e4eb905d67e8d46692174f1), + including up to release 0.16.0 from upstream +* updated MapLibre GL JS for web + +## 0.15.1, May 24, 2022 + +* cherry-picked all commits from upstream up + to [upstream release 0.15.0](https://github.com/flutter-mapbox-gl/maps/releases/tag/0.15.0) +* improved documentation +* betted adapted the example app to MapLibre +* hide logo on Android/iOS to match web + +## 0.15.0, Oct 26, 2021 + +* Fix bug when changing line color (see #448) by @vberthet + in [#15](https://github.com/m0nac0/flutter-maplibre-gl/pull/15) +* Remove unnecessary imports by @m0nac0 + in [#34](https://github.com/m0nac0/flutter-maplibre-gl/pull/34) +* Update example with Flutter 2.5.3 by @kuhnroyal + in [#35](https://github.com/m0nac0/flutter-maplibre-gl/pull/35) +* CI: Use separate scheduled pipeline for Flutter beta builds by @kuhnroyal + in [#28](https://github.com/m0nac0/flutter-maplibre-gl/pull/28) +* Null safety (cherry-pick from upstream) by @m0nac0 + in [#31](https://github.com/m0nac0/flutter-maplibre-gl/pull/31) +* [web] add missing removeLines, removeCircles and removeFills (cherry-pick + tobrun#622) by @m0nac0 + in [#32](https://github.com/m0nac0/flutter-maplibre-gl/pull/32) +* Replace style string in local style example by @m0nac0 + in [#33](https://github.com/m0nac0/flutter-maplibre-gl/pull/33) +* [web] add getSymbolLatLng and getLineLatLngs by @m0nac0 + in [#37](https://github.com/m0nac0/flutter-maplibre-gl/pull/37) + +## 0.14.0 + +### Breaking changes: + +* Remove access token, update libraries, replace example + styles [#25](https://github.com/m0nac0/flutter-maplibre-gl/pull/25) (also + see [#21](https://github.com/m0nac0/flutter-maplibre-gl/issues/21)) + * The parameter `accessToken` of class `MaplibreMap` was removed. If you + want to continue using a tile provider that requires an API key, specify + that key directly in the URL of the tile source ( + see [https://github.com/m0nac0/flutter-maplibre-gl#tile-sources-requiring-an-api-key](https://github.com/m0nac0/flutter-maplibre-gl#tile-sources-requiring-an-api-key)) + * The built-in constants for specific styles were also removed. You can + continue using these styles by using the styles' URL + +### Other changes: + +* Remove warning about missing access token on + Android [#22](https://github.com/m0nac0/flutter-maplibre-gl/pull/22) +* Example: use maplibre styles and add new demo + style [#23](https://github.com/m0nac0/flutter-maplibre-gl/pull/23) +* Add about button to example + app [#26](https://github.com/m0nac0/flutter-maplibre-gl/pull/26) +* various improvements to the CI +* fixed formatting for some files that were not correctly formatted + +## 0.13.0, Oct 6, 2021 + +🎉 The first release of flutter-maplibre-gl with the complete transition to +MapLibre libraries. 🎉 + +Further improvements: + +* Update to MapLibre-Android-SDK 9.4.2 +* Update to MapLibre-iOS-SDK 5.12.0 +* Fix onUserLocationUpdated not firing on + android [#14](https://github.com/m0nac0/flutter-maplibre-gl/pull/14) +* Add speed to + UserLocation [#11](https://github.com/m0nac0/flutter-maplibre-gl/pull/11) +* Fix queryRenderedFeaturesInRect for + iOS [#10](https://github.com/m0nac0/flutter-maplibre-gl/pull/10) + +### Changes cherry-picked/ported from tobrun/flutter-mapbox-gl:0.12.0 + +* Batch creation/removal for circles, fills and + lines [#576](https://github.com/tobrun/flutter-mapbox-gl/pull/576) +* Dependencies: updated image + package [#598](https://github.com/tobrun/flutter-mapbox-gl/pull/598) +* Improve description to enable location + features [#596](https://github.com/tobrun/flutter-mapbox-gl/pull/596) +* Fix feature manager on release + build [#593](https://github.com/tobrun/flutter-mapbox-gl/pull/593) +* Emit onTap only for the feature above the + others [#589](https://github.com/tobrun/flutter-mapbox-gl/pull/589) +* Add annotationOrder to + web [#588](https://github.com/tobrun/flutter-mapbox-gl/pull/588) + +### Changes cherry-picked/ported from tobrun/flutter-mapbox-gl:0.11.0 + +* Fixed issues caused by new android + API [#544](https://github.com/tobrun/flutter-mapbox-gl/pull/544) +* Add option to set maximum offline tile + count [#549](https://github.com/tobrun/flutter-mapbox-gl/pull/549) +* Fixed web build failure due to http package + upgrade [#550](https://github.com/tobrun/flutter-mapbox-gl/pull/550) +* Update OfflineRegion/OfflineRegionDefinition interfaces, synchronize with iOS + and Android [#545](https://github.com/tobrun/flutter-mapbox-gl/pull/545) +* Fix Mapbox GL JS CSS embedding on + web [#551](https://github.com/tobrun/flutter-mapbox-gl/pull/551) +* Update Podfile to fix iOS + CI [#565](https://github.com/tobrun/flutter-mapbox-gl/pull/565) +* Update deprecated patterns to fix CI static + analysis [#568](https://github.com/tobrun/flutter-mapbox-gl/pull/568) +* Add setOffline method on + Android [#537](https://github.com/tobrun/flutter-mapbox-gl/pull/537) +* Add batch mode of screen + locations [#554](https://github.com/tobrun/flutter-mapbox-gl/pull/554) +* Define which annotations consume the tap + events [#575](https://github.com/tobrun/flutter-mapbox-gl/pull/575) +* Remove failed offline region + downloads [#583](https://github.com/tobrun/flutter-mapbox-gl/pull/583) + +## Below is the original changelog of the tobrun/flutter-mapbox-gl project, before the fork. + +## 0.10.0, February 12, 2020 + +* Merge offline + regions [#532](https://github.com/tobrun/flutter-mapbox-gl/pull/532) +* Update offline region + metadata [#530](https://github.com/tobrun/flutter-mapbox-gl/pull/530) +* Added web support for + fills [#501](https://github.com/tobrun/flutter-mapbox-gl/pull/501) +* Support styleString as "Documents directory/Temporary + directory" [#520](https://github.com/tobrun/flutter-mapbox-gl/pull/520) +* Use offline region + ids [#491](https://github.com/tobrun/flutter-mapbox-gl/pull/491) +* Ability to define annotation layer + order [#523](https://github.com/tobrun/flutter-mapbox-gl/pull/523) +* Clear fills API [#527](https://github.com/tobrun/flutter-mapbox-gl/pull/527) +* Add heading to UserLocation and expose UserLocation + type [#522](https://github.com/tobrun/flutter-mapbox-gl/pull/522) +* Patch addFill with data + parameter [#524](https://github.com/tobrun/flutter-mapbox-gl/pull/524) +* Fix style annotation is not deselected on + iOS [#512](https://github.com/tobrun/flutter-mapbox-gl/pull/512) +* Update tracked camera position in + camera#onIdle [#500](https://github.com/tobrun/flutter-mapbox-gl/pull/500) +* Fix iOS implementation of map#toLatLng on + iOS [#495](https://github.com/tobrun/flutter-mapbox-gl/pull/495) +* Migrate to new Android flutter plugin + architecture [#488](https://github.com/tobrun/flutter-mapbox-gl/pull/488) +* Update readme to fix + UnsatisfiedLinkError [#422](https://github.com/tobrun/flutter-mapbox-gl/pull/442) +* Improved Image Source + Support [#469](https://github.com/tobrun/flutter-mapbox-gl/pull/469) +* Avoid white space when resizing map on + web [#474](https://github.com/tobrun/flutter-mapbox-gl/pull/474) +* Allow MapboxMap() to override Widget + Key. [#475](https://github.com/tobrun/flutter-mapbox-gl/pull/475) +* Offline region + feature [#336](https://github.com/tobrun/flutter-mapbox-gl/pull/336) +* Fix iOS symbol tapped + interaction [#443](https://github.com/tobrun/flutter-mapbox-gl/pull/443) + +## 0.9.0, October 24. 2020 + +* Fix data parameter for addLine and + addCircle [#388](https://github.com/tobrun/flutter-mapbox-gl/pull/388) +* Re-enable attribution on + Android [#383](https://github.com/tobrun/flutter-mapbox-gl/pull/383) +* Upgrade annotation plugin to + v0.9 [#381](https://github.com/tobrun/flutter-mapbox-gl/pull/381) +* Breaking change: CameraUpdate.newLatLngBounds() now supports setting different + padding values for left, top, right, bottom with default of 0 for all. + Implementations using the old approach with only one padding value for all + edges have to be + updated. [#382](https://github.com/tobrun/flutter-mapbox-gl/pull/382) +* web:ignore myLocationTrackingMode if myLocationEnabled is + false [#363](https://github.com/tobrun/flutter-mapbox-gl/pull/363) +* Add methods to access + projection [#380](https://github.com/tobrun/flutter-mapbox-gl/pull/380) +* Add fill API support for Android and + iOS [#49](https://github.com/tobrun/flutter-mapbox-gl/pull/49) +* Listen to OnUserLocationUpdated to provide user location to + app [#237](https://github.com/tobrun/flutter-mapbox-gl/pull/237) +* Correct integration in Activity lifecycle on + Android [#266](https://github.com/tobrun/flutter-mapbox-gl/pull/266) +* Add support for custom font stackn in symbol + options [#359](https://github.com/tobrun/flutter-mapbox-gl/pull/359) +* Fix memory leak on iOS caused by strong self + reference [#370](https://github.com/tobrun/flutter-mapbox-gl/pull/370) +* Basic ImageSource + Support [#409](https://github.com/tobrun/flutter-mapbox-gl/pull/409) +* Get meters per pixel at + latitude [#416](https://github.com/tobrun/flutter-mapbox-gl/pull/416) +* Fix + onStyleLoadedCallback [#418](https://github.com/tobrun/flutter-mapbox-gl/pull/418) + +## 0.8.0, August 22, 2020 + +- implementation of feature + querying [#177](https://github.com/tobrun/flutter-mapbox-gl/pull/177) +- Batch create/delete of + symbols [#279](https://github.com/tobrun/flutter-mapbox-gl/pull/279) +- Add multi map + support [#315](https://github.com/tobrun/flutter-mapbox-gl/pull/315) +- Fix OnCameraIdle not being + invoked [#313](https://github.com/tobrun/flutter-mapbox-gl/pull/313) +- Fix android zIndex symbol + option [#312](https://github.com/tobrun/flutter-mapbox-gl/pull/312) +- Set dependencies from + git [#319](https://github.com/tobrun/flutter-mapbox-gl/pull/319) +- Add line#getGeometry and + symbol#getGeometry [#281](https://github.com/tobrun/flutter-mapbox-gl/pull/281) + +## 0.7.0, June 6, 2020 + +* Introduction of mapbox_gl_platform_interface library +* Introduction of mapbox_gl_web library +* Integrate web support through mapbox-gl-js +* Add icon-allow-overlap configurations + +## 0.0.6, May 31, 2020 + +* Update mapbox depdendency to 9.2.0 (android) and 5.6.0 (iOS) +* Long press handlers for both iOS as Android +* Change default location tracking to none +* OnCameraIdle listener support +* Add image to style +* Add animation duration to animateCamera +* Content insets +* Visible region support on iOS +* Numerous bug fixes + +## 0.0.5, December 21, 2019 + +* iOS support for annotation extensions (circle, symbol, line) +* Update SDK to 8.5.0 (Android) and 5.5.0 (iOS) +* Integrate style loaded callback api +* Add Map click event (iOS) +* Cache management API (Android/iOS) +* Various fixes to showing user location and configurations (Android/iOS) +* Last location API (Android) +* Throttle max FPS of user location component (Android) +* Fix for handling permission handling of the test application (Android) +* Support for loading symbol images from assets (iOS/Android) + +## v0.0.4, Nov 2, 2019 + +* Update SDK to 8.4.0 (Android) and 5.4.0 (iOS) +* Add support for sideloading offline maps (Android/iOS) +* Add user tracking mode (iOS) +* Invert compassView.isHidden logic (iOS) +* Specific swift version (iOS) + +## v0.0.3, Mar 30, 2019 + +* Camera API (iOS) +* Line API (Android) +* Update codebase to AndroidX +* Update Mapbox Maps SDK for Android to v7.3.0 + +## v0.0.2, Mar 23, 2019 + +* Support for iOS +* Migration to embedded Android and iOS SDK View system +* Style URL API +* Style JSON API (Android) +* Gesture support +* Gesture restrictions (Android) +* Symbol API (Android) +* Location component (Android) +* Camera API (Android) + +## v0.0.1, May 7, 2018 + +* Initial Android surface rendering POC diff --git a/third_party/maplibre_gl/LICENSE b/third_party/maplibre_gl/LICENSE new file mode 100644 index 00000000..daaf99df --- /dev/null +++ b/third_party/maplibre_gl/LICENSE @@ -0,0 +1,181 @@ +flutter-maplibre-gl Copyright (c) 2023, MapLibre contributors. +flutter-maplibre-gl Copyright (c) 2021, m0nac0. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +------------------------------------------------------------------------------------------------------ +------------------------------------------------------------------------------------------------------ +This project was forked from (and therefore contains a lot of modified and unmodified code from) https://github.com/tobrun/flutter-mapbox-gl which at the time of the fork (March 6th, 2021) contained the following LICENSE file: +(Additionally more changes / code was later cherry-picked / ported from that repository (https://github.com/tobrun/flutter-mapbox-gl), while the license continued to be the same as at the time of the fork, which is:) +------------------------------------------------------------------------------------------------------ + +flutter-mapbox-gl copyright (c) 2018, Mapbox. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +------------------------------------------------------------------------------------------------------ +------------------------------------------------------------------------------------------------------ +This project uses the MapLibre GL native libraries for Android and iOS from https://github.com/maplibre/maplibre-gl-native, which contain the following LICENSE: +------------------------------------------------------------------------------------------------------ +BSD 2-Clause License + +Copyright (c) 2021 MapLibre contributors + +Copyright (c) 2018-2021 MapTiler.com + +Copyright (c) 2014-2020 Mapbox + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +------------------------------------------------------------------------------------------------------ +------------------------------------------------------------------------------------------------------ +This project uses MapLibre GL JS library from https://github.com/maplibre/maplibre-gl-js, which contains the following LICENSE: +------------------------------------------------------------------------------------------------------ +Copyright (c) 2020, MapLibre contributors + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the name of MapLibre GL JS nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +------------------------------------------------------------------------------- + +Contains code from mapbox-gl-js v1.13 and earlier + +Version v1.13 of mapbox-gl-js and earlier are licensed under a BSD-3-Clause license + +Copyright (c) 2020, Mapbox +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +* Neither the name of Mapbox GL JS nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +------------------------------------------------------------------------------- + +Contains code from glfx.js + +Copyright (C) 2011 by Evan Wallace + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +-------------------------------------------------------------------------------- + +Contains a portion of d3-color https://github.com/d3/d3-color + +Copyright 2010-2016 Mike Bostock +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the author nor the names of contributors may be used to + endorse or promote products derived from this software without specific prior + written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/maplibre_gl/README.md b/third_party/maplibre_gl/README.md new file mode 100644 index 00000000..7d323c9f --- /dev/null +++ b/third_party/maplibre_gl/README.md @@ -0,0 +1,394 @@ +

+ + + + MapLibre Logo + +

+ +# Flutter MapLibre GL + +[![Pub Version](https://img.shields.io/pub/v/maplibre_gl)](https://pub.dev/packages/maplibre_gl) +[![likes](https://img.shields.io/pub/likes/maplibre_gl?logo=flutter)](https://pub.dev/packages/maplibre_gl) +[![Pub Points](https://img.shields.io/pub/points/maplibre_gl)](https://pub.dev/packages/maplibre_gl/score) +[![stars](https://badgen.net/github/stars/maplibre/flutter-maplibre-gl?label=stars&color=green&icon=github)](https://github.com/josxha/flutter-maplibre-gl/stargazers) +[![melos](https://img.shields.io/badge/maintained%20with-melos-f700ff.svg?style=flat-square)](https://github.com/invertase/melos) + +Flutter MapLibre GL lets you embed **interactive, vector-tile based, fully styleable maps** directly inside your Flutter app across mobile & web. + +This project is a fork of [flutter-mapbox-gl](https://github.com/tobrun/flutter-mapbox-gl), replacing its usage of Mapbox GL libraries with the open source [MapLibre GL](https://github.com/maplibre) libraries. + +--- + +### Table of Contents + +
+Click to expand + + +* [Features overview](#features-overview) +* [Why MapLibre?](#why-maplibre) +* [Supported Platforms & API Coverage](#supported-platforms--api-coverage) +* [Installation](#installation) + * [Platform Setup](#platform-setup) + * [iOS](#ios) + * [Android](#android) + * [Web](#web) +* [Quick Start](#quick-start) +* [Usage Highlights](#usage-highlights) + * [Camera](#camera) + * [Annotations & Layers](#annotations--layers) + * [Map Styles](#map-styles) + * [Sources needing API keys](#tile-sources-requiring-an-api-key) +* [Advanced Topics](#advanced-topics) + * [Offline usage / mbtiles](#offline-usage--mbtiles) + * [PMTiles](#pmtiles) + * [Expressions & Styling](#expressions--styling) + * [Generated Code](#generated-code) + * [Architecture Overview](#architecture-overview) +* [Migration from flutter-mapbox-gl](#migration-from-flutter-mapbox-gl) +* [Performance Tips](#performance-tips) +* [Security & API Keys](#security--api-keys) +* [Troubleshooting / FAQ](#troubleshooting--faq) +* [Documentation & Examples](#documentation--examples) +* [Versioning & Changelog](#versioning--changelog) +* [Contributing](#contributing) +* [Getting Help](#getting-help) +* [License](#license) + + +
+ +--- + +## Features overview + +| Capability | Description | +|------------|-------------| +| Vector tile rendering | High quality, styleable vector maps via MapLibre engines | +| Dynamic styling | Swap or mutate styles at runtime, apply expressions | +| Camera control | Smooth programmatic & gesture driven map camera updates | +| User location | Native location indicator & tracking (permissions required) | +| Annotations | Symbols, circles, lines, fills, fill extrusions, heatmaps | +| Web support | Backed by `maplibre-gl-js` for web targets | +| Offline friendly | Patterns for mbtiles / cached assets (see advanced topics) | +| Extensible | Separate platform interface & web implementation packages | + +--- + +## Why MapLibre? + +MapLibre is a **vendor-neutral, open source** set of mapping libraries born from the community. Using MapLibre helps you: + +* Avoid vendor lock-in & proprietary billing tie-ins +* Host your own tiles or mix commercial/open sources +* Keep transparent, auditable code in production +* Align with OSS licensing and long-term sustainability +* One of the fastest map SDKs available. Capable of drawing a large number of vector objects. + +If you previously used `flutter-mapbox-gl`, you can migrate with minimal changes (see [migration guide](#migration-from-flutter-mapbox-gl)). + +--- + +## Supported Platforms & API Coverage + +Underlying engines: +* **Android / iOS** — [maplibre-native](https://github.com/maplibre/maplibre-native) +* **Web** — [maplibre-gl-js](https://github.com/maplibre/maplibre-gl-js) + +> **Note**: Only a subset of the native SDK APIs are currently exposed. PRs to extend surface area are welcome. + +| Feature | Android | iOS | Web | +|----------------|:-------:|:---:|:---:| +| Style | ✅ | ✅ | ✅ | +| Camera | ✅ | ✅ | ✅ | +| Gesture | ✅ | ✅ | ✅ | +| User Location | ✅ | ✅ | ✅ | +| Symbol | ✅ | ✅ | ✅ | +| Circle | ✅ | ✅ | ✅ | +| Line | ✅ | ✅ | ✅ | +| Fill | ✅ | ✅ | ✅ | +| Fill Extrusion | ✅ | ✅ | ✅ | +| Heatmap Layer | ✅ | ✅ | ✅ | + +--- + +## Installation + +Add the dependency: + +```yaml +dependencies: + maplibre_gl: ^LATEST_VERSION +``` + +(See the current version badge above or on [pub.dev](https://pub.dev/packages/maplibre_gl)). + +Then run: +```bash +flutter pub get +``` + +### Platform setup + +#### iOS + +Add permission usage description for location (if using location features) to `ios/Runner/Info.plist`: + +```xml +NSLocationWhenInUseUsageDescription +[Explain why the app needs the user's location] +``` + +#### Android + +Minimum supported Kotlin version: `2.1.0`. In `android/settings.gradle`: + +```groovy +plugins { + id "org.jetbrains.kotlin.android" version "2.1.0" apply false +} +``` + +Add location permissions if required in `android/app/src/main/AndroidManifest.xml`: + +```xml + + +``` + +Request permissions at runtime yourself (e.g. via the [`location`](https://pub.dev/packages/location) plugin). The plugin does not prompt automatically. + +#### Web + +Include the following JavaScript and CSS files in the `` of your `web/index.html` file: + +```html + + +``` + +--- + +## Quick Start + +```dart +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +class SimpleMapPage extends StatefulWidget { + const SimpleMapPage({super.key}); + @override + State createState() => _SimpleMapPageState(); +} + +class _SimpleMapPageState extends State { + final _controllerCompleter = Completer(); + bool _styleLoaded = false; + + static const _initial = CameraPosition(target: LatLng(0, 0), zoom: 2); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('MapLibre Quick Start')), + floatingActionButton: _styleLoaded + ? FloatingActionButton.small( + onPressed: _goHome, + child: const Icon(Icons.explore), + ) + : null, + body: MapLibreMap( + initialCameraPosition: _initial, + onMapCreated: (c) => _controllerCompleter.complete(c), + onStyleLoadedCallback: () => setState(() => _styleLoaded = true), + ), + ); + } + + Future _goHome() async { + final c = await _controllerCompleter.future; + await c.animateCamera(CameraUpdate.newCameraPosition(_initial)); + } +} +``` + +Check the [example project](./maplibre_gl_example) for richer demos (markers, layers, offline patterns, PMTiles, etc.). + +--- + +## Usage Highlights + +### Camera + +```dart +await controller.animateCamera( + CameraUpdate.newLatLngBounds(bounds, left: 24, top: 24, right: 24, bottom: 24), +); +``` + +### Annotations & Layers + +Add symbols (markers): +```dart +await controller.addSymbol(SymbolOptions( + geometry: LatLng(37.7749, -122.4194), + iconImage: 'assets/icon_pin.png', // ensure added as style image first + iconSize: 1.2, +)); +``` + +Add line layer (via geojson source) – see example app for full workflow. + +### Map Styles + +Provide a `styleString`: +1. Remote URL (`https://.../style.json`) +2. App asset (`assets/map_style.json` with pubspec asset registration) +3. Local file system absolute path +4. Raw JSON string + +### Tile sources requiring an API key + +Embed the key directly in the vector tile URL: + +``` +https://tiles.example.com/{z}/{x}/{y}.vector.pbf?api_key=YOUR_KEY +``` + +--- + +## Advanced Topics + +### Offline usage / mbtiles +Copy mbtiles (or sprites/glyphs) from bundled assets to a writable directory (e.g. cache) and point your style sources there. See issues [#338](https://github.com/maplibre/flutter-maplibre-gl/issues/338) & [#318](https://github.com/maplibre/flutter-maplibre-gl/issues/318) for community approaches. + +### PMTiles +The example app includes a `pmtiles` usage sample demonstrating how to load datasets via a custom protocol handler / tile source. (Look for `pmtiles.dart` in `maplibre_gl_example`.) + +### Expressions & Styling +Use data‑driven styling with expressions similar to Mapbox / MapLibre style spec. Some platform discrepancies exist (see FAQ around `!has`). For cross‑platform safety prefer form: `["!", ["has", "field"]]`. + +### Generated Code +Layer/source property helpers & expression utilities are generated from templates under `scripts/`. Do **not** edit generated files directly—run: + +``` +melos run generate +melos format-all +``` + +### Architecture Overview + +This repository is a multi-package workspace: +* `maplibre_gl` – main Flutter plugin (mobile/native bindings) +* `maplibre_gl_web` – web implementation +* `maplibre_gl_platform_interface` – shared platform interface (enables adding alternative implementations) +* `scripts/` – code generation templates & tooling + +--- + +## Migration from flutter-mapbox-gl + +Most APIs are source-compatible. Key differences to watch: +* Dependency name changes to `maplibre_gl`. +* Remove any Mapbox token initialization (MapLibre uses open assets or your own tile endpoints). +* If you referenced Mapbox-specific style endpoints, replace with self-hosted / MapLibre friendly ones. +* Audit expressions for iOS compatibility (`["!has", ...]` variant change noted in FAQ). + +--- + +## Performance Tips + +* Reuse a single `MapLibreMap` widget when possible instead of recreating it in tab/page switches. +* Batch style mutations (e.g. add sources before adding dependent layers) to avoid intermediate layout recalculations. +* Use simpler geometries or tiling strategies for very dense data. +* Avoid large uncompressed GeoJSON inline; host as a URL source if size is large. +* Defer camera animations until style load (`onStyleLoadedCallback`). + +--- + +## Security & API Keys + +If you embed API keys in style URLs for third-party tiles, ensure you: +* Restrict keys at the provider (domain / referer / usage caps). +* Avoid shipping unnecessary privileges (read-only tile scopes where possible). +Environment variable injection at build time is recommended for CI-driven apps—avoid committing raw secrets. + +--- + +## Troubleshooting / FAQ + +### Loading .mbtiles / sprites / glyphs from app assets +Copy them to a writable directory (cache/documents) first, then reference the new path in the style or source configuration. See issues [#338](https://github.com/maplibre/flutter-maplibre-gl/issues/338) & [#318](https://github.com/maplibre/flutter-maplibre-gl/issues/318). + +### Android UnsatisfiedLinkError +Ensure `abiFilters` include the ABIs you intend to ship: +```gradle +buildTypes { + release { + ndk { abiFilters 'armeabi-v7a','arm64-v8a','x86_64','x86' } + } +} +``` + +### iOS crash when using location features +Add `NSLocationWhenInUseUsageDescription` (see [iOS setup](#ios)). + + +### iOS filter expression error +Error: `Invalid filter value: filter property must be a string`. Replace `["!has", "value"]` with `["!", ["has", "value"]]`. + +--- + +## Documentation & Examples + +* Minimal example (for pub.dev & quick start): see [`example/lib/main.dart`](./example/lib/main.dart) +* Full featured example app: [`maplibre_gl_example`](./maplibre_gl_example) +* API docs: https://pub.dev/documentation/maplibre_gl/latest/ +* MapLibre upstream projects: [maplibre-gl-js](https://github.com/maplibre/maplibre-gl-js), [maplibre-native](https://github.com/maplibre/maplibre-native) + +--- + +## Versioning & Changelog + +Releases follow semantic versioning as practical; breaking changes are documented in the [CHANGELOG](./CHANGELOG.md) (root and per package). Always consult the changelog when upgrading. + +--- + +## Contributing + +This is a multi-package workspace managed by [melos](https://melos.invertase.dev/~melos-latest/getting-started). + +Basic flow: +``` +dart pub global activate melos # activate melos package + +melos bootstrap # initialize & link local packages +``` +Then open & run the example app to validate and check changes. + +Please read the full [CONTRIBUTING.md](./CONTRIBUTING.md) before submitting a PR. + +> Generated code: Some API surface (layer/source property helpers, expression utilities) is produced via a generator under `scripts/`. Do not modify generated files directly — see [Code Generation & Formatting](#generated-code) section for workflow. + +--- + +## Getting Help +* Join our [Slack](https://slack.openstreetmap.us/) channel +* StackOverflow tag: [#maplibre](https://stackoverflow.com/questions/tagged/maplibre) +* Discussions: https://github.com/maplibre/flutter-maplibre-gl/discussions +* Bugs / Features: [Open an issue](https://github.com/maplibre/flutter-maplibre-gl/issues/new) (include reproduction details/logs where possible) + +--- + +## License + +See [LICENSE](./LICENSE) for details. + +--- + +\ +If this plugin helps you build something cool, consider starring the repo to support visibility. diff --git a/third_party/maplibre_gl/analysis_options.yaml b/third_party/maplibre_gl/analysis_options.yaml new file mode 100644 index 00000000..0caccc81 --- /dev/null +++ b/third_party/maplibre_gl/analysis_options.yaml @@ -0,0 +1 @@ +include: ../analysis_options.yaml \ No newline at end of file diff --git a/third_party/maplibre_gl/android/build.gradle b/third_party/maplibre_gl/android/build.gradle new file mode 100644 index 00000000..114f4ec2 --- /dev/null +++ b/third_party/maplibre_gl/android/build.gradle @@ -0,0 +1,62 @@ +group 'org.maplibre.maplibregl' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '2.3.0' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:8.13.2' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + if (project.android.hasProperty("namespace")) { + namespace 'org.maplibre.maplibregl' + } + compileSdkVersion 36 + buildToolsVersion "36.0.0" + + ndkVersion "28.1.13356709" + + defaultConfig { + minSdkVersion 21 + compileSdk 36 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + multiDexEnabled true + } + lintOptions { + disable 'InvalidPackage' + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_21.toString() + } + dependencies { + implementation 'org.maplibre.gl:android-sdk:12.3.1' + implementation 'org.maplibre.gl:android-plugin-annotation-v9:3.0.2' + implementation 'org.maplibre.gl:android-plugin-offline-v9:3.0.2' + implementation 'com.squareup.okhttp3:okhttp:5.3.2' + } +} + +dependencies { + implementation 'androidx.core:core-ktx:1.17.0' +} diff --git a/third_party/maplibre_gl/android/gradle.properties b/third_party/maplibre_gl/android/gradle.properties new file mode 100644 index 00000000..48989cb9 --- /dev/null +++ b/third_party/maplibre_gl/android/gradle.properties @@ -0,0 +1,2 @@ +android.useAndroidX=true +org.gradle.jvmargs=-Xmx1536M diff --git a/third_party/maplibre_gl/android/gradle/wrapper/gradle-wrapper.jar b/third_party/maplibre_gl/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..d64cd491 Binary files /dev/null and b/third_party/maplibre_gl/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/third_party/maplibre_gl/android/gradle/wrapper/gradle-wrapper.properties b/third_party/maplibre_gl/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..4a202fd8 --- /dev/null +++ b/third_party/maplibre_gl/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +#Thu Mar 06 17:48:28 CET 2025 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/third_party/maplibre_gl/android/settings.gradle b/third_party/maplibre_gl/android/settings.gradle new file mode 100644 index 00000000..2ea73f08 --- /dev/null +++ b/third_party/maplibre_gl/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'maplibre_gl' diff --git a/third_party/maplibre_gl/android/src/main/AndroidManifest.xml b/third_party/maplibre_gl/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..1d2dc33d --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/Convert.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/Convert.java new file mode 100644 index 00000000..7e070ecc --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/Convert.java @@ -0,0 +1,325 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package org.maplibre.maplibregl; + +import android.content.Context; +import android.graphics.Point; +import android.util.DisplayMetrics; +import android.util.Log; +import org.maplibre.android.location.engine.LocationEngineRequest; +import org.maplibre.geojson.Polygon; +import org.maplibre.android.camera.CameraPosition; +import org.maplibre.android.camera.CameraUpdate; +import org.maplibre.android.camera.CameraUpdateFactory; +import org.maplibre.android.geometry.LatLng; +import org.maplibre.android.geometry.LatLngBounds; +import org.maplibre.android.maps.MapLibreMap; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** Conversions between JSON-like values and MapLibreMaps data types. */ +class Convert { + + private static final String TAG = "Convert"; + + static boolean toBoolean(Object o) { + return (Boolean) o; + } + + static CameraPosition toCameraPosition(Object o) { + final Map data = toMap(o); + final CameraPosition.Builder builder = new CameraPosition.Builder(); + builder.bearing(toFloat(data.get("bearing"))); + builder.target(toLatLng(data.get("target"))); + builder.tilt(toFloat(data.get("tilt"))); + builder.zoom(toFloat(data.get("zoom"))); + return builder.build(); + } + + static boolean isScrollByCameraUpdate(Object o) { + return toString(toList(o).get(0)).equals("scrollBy"); + } + + static CameraUpdate toCameraUpdate(Object o, MapLibreMap maplibreMap, float density) { + final List data = toList(o); + switch (toString(data.get(0))) { + case "newCameraPosition": + return CameraUpdateFactory.newCameraPosition(toCameraPosition(data.get(1))); + case "newLatLng": + return CameraUpdateFactory.newLatLng(toLatLng(data.get(1))); + case "newLatLngBounds": + return CameraUpdateFactory.newLatLngBounds( + toLatLngBounds(data.get(1)), + toPixels(data.get(2), density), + toPixels(data.get(3), density), + toPixels(data.get(4), density), + toPixels(data.get(5), density)); + case "newLatLngZoom": + return CameraUpdateFactory.newLatLngZoom(toLatLng(data.get(1)), toFloat(data.get(2))); + case "scrollBy": + maplibreMap.scrollBy( + toFractionalPixels(data.get(1), density), toFractionalPixels(data.get(2), density)); + return null; + case "zoomBy": + if (data.size() == 2) { + return CameraUpdateFactory.zoomBy(toFloat(data.get(1))); + } else { + return CameraUpdateFactory.zoomBy(toFloat(data.get(1)), toPoint(data.get(2), density)); + } + case "zoomIn": + return CameraUpdateFactory.zoomIn(); + case "zoomOut": + return CameraUpdateFactory.zoomOut(); + case "zoomTo": + return CameraUpdateFactory.zoomTo(toFloat(data.get(1))); + case "bearingTo": + return CameraUpdateFactory.bearingTo(toFloat(data.get(1))); + case "tiltTo": + return CameraUpdateFactory.tiltTo(toFloat(data.get(1))); + default: + throw new IllegalArgumentException("Cannot interpret " + o + " as CameraUpdate"); + } + } + + static double toDouble(Object o) { + return ((Number) o).doubleValue(); + } + + static float toFloat(Object o) { + return ((Number) o).floatValue(); + } + + static Float toFloatWrapper(Object o) { + return (o == null) ? null : toFloat(o); + } + + static int toInt(Object o) { + return ((Number) o).intValue(); + } + + static Object toJson(CameraPosition position) { + if (position == null) { + return null; + } + final Map data = new HashMap<>(); + data.put("bearing", position.bearing); + data.put("target", toJson(position.target)); + data.put("tilt", position.tilt); + data.put("zoom", position.zoom); + return data; + } + + private static Object toJson(LatLng latLng) { + return Arrays.asList(latLng.getLatitude(), latLng.getLongitude()); + } + + static LatLng toLatLng(Object o) { + final List data = toList(o); + return new LatLng(toDouble(data.get(0)), toDouble(data.get(1))); + } + + static LatLngBounds toLatLngBounds(Object o) { + if (o == null) { + return null; + } + final List data = toList(o); + LatLng[] boundsArray = new LatLng[] {toLatLng(data.get(0)), toLatLng(data.get(1))}; + List bounds = Arrays.asList(boundsArray); + LatLngBounds.Builder builder = new LatLngBounds.Builder(); + builder.includes(bounds); + return builder.build(); + } + +static LocationEngineRequest toLocationEngineRequest(Object o) { + if (o == null) { + return null; + } + List data = toList(o); + return new LocationEngineRequest.Builder(toInt(data.get(0))) + .setPriority(toInt(data.get(1))) + .setDisplacement(toInt(data.get(2))) + .build(); +} + + static List toLatLngList(Object o, boolean flippedOrder) { + if (o == null) { + return null; + } + final List data = toList(o); + List latLngList = new ArrayList<>(); + for (int i = 0; i < data.size(); i++) { + final List coords = toList(data.get(i)); + if (flippedOrder) { + latLngList.add(new LatLng(toDouble(coords.get(1)), toDouble(coords.get(0)))); + } else { + latLngList.add(new LatLng(toDouble(coords.get(0)), toDouble(coords.get(1)))); + } + } + return latLngList; + } + + private static List> toLatLngListList(Object o) { + if (o == null) { + return null; + } + final List data = toList(o); + List> latLngListList = new ArrayList<>(); + for (int i = 0; i < data.size(); i++) { + List latLngList = toLatLngList(data.get(i), false); + latLngListList.add(latLngList); + } + return latLngListList; + } + + static Polygon interpretListLatLng(List> geometry) { + List> points = new ArrayList<>(geometry.size()); + for (List innerGeometry : geometry) { + List innerPoints = new ArrayList<>(innerGeometry.size()); + for (LatLng latLng : innerGeometry) { + innerPoints.add( + org.maplibre.geojson.Point.fromLngLat(latLng.getLongitude(), latLng.getLatitude())); + } + points.add(innerPoints); + } + return Polygon.fromLngLats(points); + } + + static List toList(Object o) { + return (List) o; + } + + static long toLong(Object o) { + return ((Number) o).longValue(); + } + + static Map toMap(Object o) { + return (Map) o; + } + + private static float toFractionalPixels(Object o, float density) { + return toFloat(o) * density; + } + + static int toPixels(Object o, float density) { + return (int) toFractionalPixels(o, density); + } + + private static Point toPoint(Object o, float density) { + final List data = toList(o); + return new Point(toPixels(data.get(0), density), toPixels(data.get(1), density)); + } + + static String toString(Object o) { + return (String) o; + } + + static void interpretMapLibreMapOptions(Object o, MapLibreMapOptionsSink sink, Context context) { + final DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + final Map data = toMap(o); + + final Object locationEngineProperties = data.get("locationEngineProperties"); + if (locationEngineProperties != null) { + final List locationEnginePropertiesList = toList(locationEngineProperties); + sink.setLocationEngineProperties(toLocationEngineRequest(locationEnginePropertiesList)); + } + final Object cameraTargetBounds = data.get("cameraTargetBounds"); + if (cameraTargetBounds != null) { + final List targetData = toList(cameraTargetBounds); + sink.setCameraTargetBounds(toLatLngBounds(targetData.get(0))); + } + final Object compassEnabled = data.get("compassEnabled"); + if (compassEnabled != null) { + sink.setCompassEnabled(toBoolean(compassEnabled)); + } + final Object styleString = data.get("styleString"); + if (styleString != null) { + sink.setStyleString(toString(styleString)); + } + final Object minMaxZoomPreference = data.get("minMaxZoomPreference"); + if (minMaxZoomPreference != null) { + final List zoomPreferenceData = toList(minMaxZoomPreference); + sink.setMinMaxZoomPreference( // + toFloatWrapper(zoomPreferenceData.get(0)), // + toFloatWrapper(zoomPreferenceData.get(1))); + } + final Object rotateGesturesEnabled = data.get("rotateGesturesEnabled"); + if (rotateGesturesEnabled != null) { + sink.setRotateGesturesEnabled(toBoolean(rotateGesturesEnabled)); + } + final Object scrollGesturesEnabled = data.get("scrollGesturesEnabled"); + if (scrollGesturesEnabled != null) { + sink.setScrollGesturesEnabled(toBoolean(scrollGesturesEnabled)); + } + final Object tiltGesturesEnabled = data.get("tiltGesturesEnabled"); + if (tiltGesturesEnabled != null) { + sink.setTiltGesturesEnabled(toBoolean(tiltGesturesEnabled)); + } + final Object trackCameraPosition = data.get("trackCameraPosition"); + if (trackCameraPosition != null) { + sink.setTrackCameraPosition(toBoolean(trackCameraPosition)); + } + final Object zoomGesturesEnabled = data.get("zoomGesturesEnabled"); + if (zoomGesturesEnabled != null) { + sink.setZoomGesturesEnabled(toBoolean(zoomGesturesEnabled)); + } + final Object myLocationEnabled = data.get("myLocationEnabled"); + if (myLocationEnabled != null) { + sink.setMyLocationEnabled(toBoolean(myLocationEnabled)); + } + final Object myLocationTrackingMode = data.get("myLocationTrackingMode"); + if (myLocationTrackingMode != null) { + sink.setMyLocationTrackingMode(toInt(myLocationTrackingMode)); + } + final Object myLocationRenderMode = data.get("myLocationRenderMode"); + if (myLocationRenderMode != null) { + sink.setMyLocationRenderMode(toInt(myLocationRenderMode)); + } + final Object logoEnabled = data.get("logoEnabled"); + if (logoEnabled != null) { + sink.setLogoEnabled(toBoolean(logoEnabled)); + } + final Object logoViewGravity = data.get("logoViewPosition"); + if (logoViewGravity != null) { + sink.setLogoViewGravity(toInt(logoViewGravity)); + } + final Object logoViewMargins = data.get("logoViewMargins"); + if (logoViewMargins != null) { + final List logoViewMarginsData = toList(logoViewMargins); + final Point point = toPoint(logoViewMarginsData, metrics.density); + sink.setLogoViewMargins(point.x, point.y); + } + final Object compassGravity = data.get("compassViewPosition"); + if (compassGravity != null) { + sink.setCompassGravity(toInt(compassGravity)); + } + final Object compassViewMargins = data.get("compassViewMargins"); + if (compassViewMargins != null) { + final List compassViewMarginsData = toList(compassViewMargins); + final Point point = toPoint(compassViewMarginsData, metrics.density); + sink.setCompassViewMargins(point.x, point.y); + } + final Object attributionButtonGravity = data.get("attributionButtonPosition"); + if (attributionButtonGravity != null) { + sink.setAttributionButtonGravity(toInt(attributionButtonGravity)); + } + final Object attributionButtonMargins = data.get("attributionButtonMargins"); + if (attributionButtonMargins != null) { + final List attributionButtonMarginsData = toList(attributionButtonMargins); + final Point point = toPoint(attributionButtonMarginsData, metrics.density); + sink.setAttributionButtonMargins(point.x, point.y); + } + final Object foregroundLoadColor = data.get("foregroundLoadColor"); + if (foregroundLoadColor != null) { + sink.setForegroundLoadColor(toInt(foregroundLoadColor)); + } + final Object translucentTextureSurface = data.get("translucentTextureSurface"); + if (translucentTextureSurface != null) { + sink.setTranslucentTextureSurface(toBoolean(translucentTextureSurface)); + } + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/GlobalMethodHandler.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/GlobalMethodHandler.java new file mode 100644 index 00000000..edb8d2ee --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/GlobalMethodHandler.java @@ -0,0 +1,157 @@ +package org.maplibre.maplibregl; + +import android.content.Context; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import org.maplibre.android.net.ConnectivityReceiver; +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.PluginRegistry; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Map; + +class GlobalMethodHandler implements MethodChannel.MethodCallHandler { + private static final String TAG = GlobalMethodHandler.class.getSimpleName(); + private static final String DATABASE_NAME = "mbgl-offline.db"; + private static final int BUFFER_SIZE = 1024 * 2; + @NonNull private final Context context; + @NonNull private final BinaryMessenger messenger; + @Nullable private FlutterPlugin.FlutterAssets flutterAssets; + @Nullable private OfflineChannelHandlerImpl downloadOfflineRegionChannelHandler; + + + GlobalMethodHandler(@NonNull FlutterPlugin.FlutterPluginBinding binding) { + this.context = binding.getApplicationContext(); + this.flutterAssets = binding.getFlutterAssets(); + this.messenger = binding.getBinaryMessenger(); + } + + private static void copy(InputStream input, OutputStream output) throws IOException { + final byte[] buffer = new byte[BUFFER_SIZE]; + final BufferedInputStream in = new BufferedInputStream(input, BUFFER_SIZE); + final BufferedOutputStream out = new BufferedOutputStream(output, BUFFER_SIZE); + int count = 0; + int n = 0; + try { + while ((n = in.read(buffer, 0, BUFFER_SIZE)) != -1) { + out.write(buffer, 0, n); + count += n; + } + out.flush(); + } finally { + try { + out.close(); + } catch (IOException e) { + Log.e(TAG, e.getMessage(), e); + } + try { + in.close(); + } catch (IOException e) { + Log.e(TAG, e.getMessage(), e); + } + } + } + + @Override + public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) { + MapLibreUtils.getMapLibre(context); + + switch (methodCall.method) { + case "installOfflineMapTiles": + String tilesDb = methodCall.argument("tilesdb"); + installOfflineMapTiles(tilesDb); + result.success(null); + break; + case "setOffline": + boolean offline = methodCall.argument("offline"); + ConnectivityReceiver.instance(context).setConnected(offline ? false : null); + result.success(null); + break; + case "mergeOfflineRegions": + OfflineManagerUtils.mergeRegions(result, context, methodCall.argument("path")); + break; + case "setOfflineTileCountLimit": + OfflineManagerUtils.setOfflineTileCountLimit( + result, context, methodCall.argument("limit").longValue()); + break; + case "setHttpHeaders": + Map headers = (Map) methodCall.argument("headers"); + MapLibreHttpRequestUtil.setHttpHeaders(headers, result); + break; + case "downloadOfflineRegion#setup": + String channelName = methodCall.argument("channelName"); + // Prepare args + downloadOfflineRegionChannelHandler = new OfflineChannelHandlerImpl(messenger, channelName); + result.success(null); + break; + case "downloadOfflineRegion": + // Get args from caller + Map definitionMap = (Map) methodCall.argument("definition"); + Map metadataMap = (Map) methodCall.argument("metadata"); + + if (downloadOfflineRegionChannelHandler == null) { + result.error( + "downloadOfflineRegion#setup NOT CALLED", + "The setup has not been called, please call downloadOfflineRegion#setup before", + null); + break; + } + + // Start downloading + OfflineManagerUtils.downloadRegion( + result, context, definitionMap, metadataMap, downloadOfflineRegionChannelHandler); + downloadOfflineRegionChannelHandler = null; + break; + case "getListOfRegions": + OfflineManagerUtils.regionsList(result, context); + break; + case "updateOfflineRegionMetadata": + // Get download region arguments from caller + Map metadata = (Map) methodCall.argument("metadata"); + OfflineManagerUtils.updateRegionMetadata( + result, context, methodCall.argument("id").longValue(), metadata); + break; + case "deleteOfflineRegion": + OfflineManagerUtils.deleteRegion( + result, context, methodCall.argument("id").longValue()); + break; + default: + result.notImplemented(); + break; + } + } + + private void installOfflineMapTiles(String tilesDb) { + final File dest = new File(context.getFilesDir(), DATABASE_NAME); + try (InputStream input = openTilesDbFile(tilesDb); + OutputStream output = new FileOutputStream(dest)) { + copy(input, output); + } catch (IOException e) { + e.printStackTrace(); + } + } + + private InputStream openTilesDbFile(String tilesDb) throws IOException { + if (tilesDb.startsWith("/")) { // Absolute path. + return new FileInputStream(new File(tilesDb)); + } else { + String assetKey; + if (flutterAssets != null) { + assetKey = flutterAssets.getAssetFilePathByName(tilesDb); + } else { + throw new IllegalStateException(); + } + return context.getAssets().open(assetKey); + } + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/LayerPropertyConverter.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/LayerPropertyConverter.java new file mode 100644 index 00000000..2531e908 --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/LayerPropertyConverter.java @@ -0,0 +1,659 @@ +// This file is generated by +// ./scripts/lib/generate.dart + +package org.maplibre.maplibregl; + +import org.maplibre.android.style.expressions.Expression; +import org.maplibre.android.style.layers.PropertyFactory; +import org.maplibre.android.style.layers.PropertyValue; + +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; + +import static org.maplibre.maplibregl.Convert.toMap; + +class LayerPropertyConverter { + static PropertyValue[] interpretSymbolLayerProperties(Object o) { + final Map data = (Map) toMap(o); + final List properties = new LinkedList(); + final JsonParser parser = new JsonParser(); + + for (Map.Entry entry : data.entrySet()) { + final JsonElement jsonElement = parser.parse(entry.getValue()); + Expression expression = Expression.Converter.convert(jsonElement); + switch (entry.getKey()) { + case "icon-opacity": + properties.add(PropertyFactory.iconOpacity(expression)); + break; + case "icon-color": + properties.add(PropertyFactory.iconColor(expression)); + break; + case "icon-halo-color": + properties.add(PropertyFactory.iconHaloColor(expression)); + break; + case "icon-halo-width": + properties.add(PropertyFactory.iconHaloWidth(expression)); + break; + case "icon-halo-blur": + properties.add(PropertyFactory.iconHaloBlur(expression)); + break; + case "icon-translate": + if (jsonElement.isJsonArray()) { + final Float[] floatArray = convertJsonToFloatArray(jsonElement); + if (floatArray != null) { + properties.add(PropertyFactory.iconTranslate(floatArray)); + } else { + properties.add(PropertyFactory.iconTranslate(expression)); + } + } else { + properties.add(PropertyFactory.iconTranslate(expression)); + } + break; + case "icon-translate-anchor": + properties.add(PropertyFactory.iconTranslateAnchor(expression)); + break; + case "text-opacity": + properties.add(PropertyFactory.textOpacity(expression)); + break; + case "text-color": + properties.add(PropertyFactory.textColor(expression)); + break; + case "text-halo-color": + properties.add(PropertyFactory.textHaloColor(expression)); + break; + case "text-halo-width": + properties.add(PropertyFactory.textHaloWidth(expression)); + break; + case "text-halo-blur": + properties.add(PropertyFactory.textHaloBlur(expression)); + break; + case "text-translate": + if (jsonElement.isJsonArray()) { + final Float[] floatArray = convertJsonToFloatArray(jsonElement); + if (floatArray != null) { + properties.add(PropertyFactory.textTranslate(floatArray)); + } else { + properties.add(PropertyFactory.textTranslate(expression)); + } + } else { + properties.add(PropertyFactory.textTranslate(expression)); + } + break; + case "text-translate-anchor": + properties.add(PropertyFactory.textTranslateAnchor(expression)); + break; + case "symbol-placement": + properties.add(PropertyFactory.symbolPlacement(expression)); + break; + case "symbol-spacing": + properties.add(PropertyFactory.symbolSpacing(expression)); + break; + case "symbol-avoid-edges": + properties.add(PropertyFactory.symbolAvoidEdges(expression)); + break; + case "symbol-sort-key": + properties.add(PropertyFactory.symbolSortKey(expression)); + break; + case "symbol-z-order": + properties.add(PropertyFactory.symbolZOrder(expression)); + break; + case "icon-allow-overlap": + properties.add(PropertyFactory.iconAllowOverlap(expression)); + break; + case "icon-ignore-placement": + properties.add(PropertyFactory.iconIgnorePlacement(expression)); + break; + case "icon-optional": + properties.add(PropertyFactory.iconOptional(expression)); + break; + case "icon-rotation-alignment": + properties.add(PropertyFactory.iconRotationAlignment(expression)); + break; + case "icon-size": + properties.add(PropertyFactory.iconSize(expression)); + break; + case "icon-text-fit": + properties.add(PropertyFactory.iconTextFit(expression)); + break; + case "icon-text-fit-padding": + if (jsonElement.isJsonArray()) { + final Float[] floatArray = convertJsonToFloatArray(jsonElement); + if (floatArray != null) { + properties.add(PropertyFactory.iconTextFitPadding(floatArray)); + } else { + properties.add(PropertyFactory.iconTextFitPadding(expression)); + } + } else { + properties.add(PropertyFactory.iconTextFitPadding(expression)); + } + break; + case "icon-image": + if (jsonElement.isJsonPrimitive() && jsonElement.getAsJsonPrimitive().isString()) { + properties.add(PropertyFactory.iconImage(jsonElement.getAsString())); + } else { + properties.add(PropertyFactory.iconImage(expression)); + } + break; + case "icon-rotate": + properties.add(PropertyFactory.iconRotate(expression)); + break; + case "icon-padding": + properties.add(PropertyFactory.iconPadding(expression)); + break; + case "icon-keep-upright": + properties.add(PropertyFactory.iconKeepUpright(expression)); + break; + case "icon-offset": + if (jsonElement.isJsonArray()) { + final Float[] floatArray = convertJsonToFloatArray(jsonElement); + if (floatArray != null) { + properties.add(PropertyFactory.iconOffset(floatArray)); + } else { + properties.add(PropertyFactory.iconOffset(expression)); + } + } else { + properties.add(PropertyFactory.iconOffset(expression)); + } + break; + case "icon-anchor": + properties.add(PropertyFactory.iconAnchor(expression)); + break; + case "icon-pitch-alignment": + properties.add(PropertyFactory.iconPitchAlignment(expression)); + break; + case "text-pitch-alignment": + properties.add(PropertyFactory.textPitchAlignment(expression)); + break; + case "text-rotation-alignment": + properties.add(PropertyFactory.textRotationAlignment(expression)); + break; + case "text-field": + properties.add(PropertyFactory.textField(expression)); + break; + case "text-font": + properties.add(PropertyFactory.textFont(expression)); + break; + case "text-size": + properties.add(PropertyFactory.textSize(expression)); + break; + case "text-max-width": + properties.add(PropertyFactory.textMaxWidth(expression)); + break; + case "text-line-height": + properties.add(PropertyFactory.textLineHeight(expression)); + break; + case "text-letter-spacing": + properties.add(PropertyFactory.textLetterSpacing(expression)); + break; + case "text-justify": + properties.add(PropertyFactory.textJustify(expression)); + break; + case "text-radial-offset": + properties.add(PropertyFactory.textRadialOffset(expression)); + break; + case "text-variable-anchor": + properties.add(PropertyFactory.textVariableAnchor(expression)); + break; + case "text-anchor": + properties.add(PropertyFactory.textAnchor(expression)); + break; + case "text-max-angle": + properties.add(PropertyFactory.textMaxAngle(expression)); + break; + case "text-writing-mode": + properties.add(PropertyFactory.textWritingMode(expression)); + break; + case "text-rotate": + properties.add(PropertyFactory.textRotate(expression)); + break; + case "text-padding": + properties.add(PropertyFactory.textPadding(expression)); + break; + case "text-keep-upright": + properties.add(PropertyFactory.textKeepUpright(expression)); + break; + case "text-transform": + properties.add(PropertyFactory.textTransform(expression)); + break; + case "text-offset": + if (jsonElement.isJsonArray()) { + final Float[] floatArray = convertJsonToFloatArray(jsonElement); + if (floatArray != null) { + properties.add(PropertyFactory.textOffset(floatArray)); + } else { + properties.add(PropertyFactory.textOffset(expression)); + } + } else { + properties.add(PropertyFactory.textOffset(expression)); + } + break; + case "text-allow-overlap": + properties.add(PropertyFactory.textAllowOverlap(expression)); + break; + case "text-ignore-placement": + properties.add(PropertyFactory.textIgnorePlacement(expression)); + break; + case "text-optional": + properties.add(PropertyFactory.textOptional(expression)); + break; + case "visibility": + properties.add(PropertyFactory.visibility(entry.getValue().substring(1, entry.getValue().length() - 1))); + break; + default: + break; + } + } + + return properties.toArray(new PropertyValue[properties.size()]); + } + + static PropertyValue[] interpretCircleLayerProperties(Object o) { + final Map data = (Map) toMap(o); + final List properties = new LinkedList(); + final JsonParser parser = new JsonParser(); + + for (Map.Entry entry : data.entrySet()) { + final JsonElement jsonElement = parser.parse(entry.getValue()); + Expression expression = Expression.Converter.convert(jsonElement); + switch (entry.getKey()) { + case "circle-radius": + properties.add(PropertyFactory.circleRadius(expression)); + break; + case "circle-color": + properties.add(PropertyFactory.circleColor(expression)); + break; + case "circle-blur": + properties.add(PropertyFactory.circleBlur(expression)); + break; + case "circle-opacity": + properties.add(PropertyFactory.circleOpacity(expression)); + break; + case "circle-translate": + if (jsonElement.isJsonArray()) { + final Float[] floatArray = convertJsonToFloatArray(jsonElement); + if (floatArray != null) { + properties.add(PropertyFactory.circleTranslate(floatArray)); + } else { + properties.add(PropertyFactory.circleTranslate(expression)); + } + } else { + properties.add(PropertyFactory.circleTranslate(expression)); + } + break; + case "circle-translate-anchor": + properties.add(PropertyFactory.circleTranslateAnchor(expression)); + break; + case "circle-pitch-scale": + properties.add(PropertyFactory.circlePitchScale(expression)); + break; + case "circle-pitch-alignment": + properties.add(PropertyFactory.circlePitchAlignment(expression)); + break; + case "circle-stroke-width": + properties.add(PropertyFactory.circleStrokeWidth(expression)); + break; + case "circle-stroke-color": + properties.add(PropertyFactory.circleStrokeColor(expression)); + break; + case "circle-stroke-opacity": + properties.add(PropertyFactory.circleStrokeOpacity(expression)); + break; + case "circle-sort-key": + properties.add(PropertyFactory.circleSortKey(expression)); + break; + case "visibility": + properties.add(PropertyFactory.visibility(entry.getValue().substring(1, entry.getValue().length() - 1))); + break; + default: + break; + } + } + + return properties.toArray(new PropertyValue[properties.size()]); + } + + static PropertyValue[] interpretLineLayerProperties(Object o) { + final Map data = (Map) toMap(o); + final List properties = new LinkedList(); + final JsonParser parser = new JsonParser(); + + for (Map.Entry entry : data.entrySet()) { + final JsonElement jsonElement = parser.parse(entry.getValue()); + Expression expression = Expression.Converter.convert(jsonElement); + switch (entry.getKey()) { + case "line-opacity": + properties.add(PropertyFactory.lineOpacity(expression)); + break; + case "line-color": + properties.add(PropertyFactory.lineColor(expression)); + break; + case "line-translate": + if (jsonElement.isJsonArray()) { + final Float[] floatArray = convertJsonToFloatArray(jsonElement); + if (floatArray != null) { + properties.add(PropertyFactory.lineTranslate(floatArray)); + } else { + properties.add(PropertyFactory.lineTranslate(expression)); + } + } else { + properties.add(PropertyFactory.lineTranslate(expression)); + } + break; + case "line-translate-anchor": + properties.add(PropertyFactory.lineTranslateAnchor(expression)); + break; + case "line-width": + properties.add(PropertyFactory.lineWidth(expression)); + break; + case "line-gap-width": + properties.add(PropertyFactory.lineGapWidth(expression)); + break; + case "line-offset": + properties.add(PropertyFactory.lineOffset(expression)); + break; + case "line-blur": + properties.add(PropertyFactory.lineBlur(expression)); + break; + case "line-dasharray": + if (jsonElement.isJsonArray()) { + final Float[] floatArray = convertJsonToFloatArray(jsonElement); + if (floatArray != null) { + properties.add(PropertyFactory.lineDasharray(floatArray)); + } else { + properties.add(PropertyFactory.lineDasharray(expression)); + } + } else { + properties.add(PropertyFactory.lineDasharray(expression)); + } + break; + case "line-pattern": + if (jsonElement.isJsonPrimitive() && jsonElement.getAsJsonPrimitive().isString()) { + properties.add(PropertyFactory.linePattern(jsonElement.getAsString())); + } else { + properties.add(PropertyFactory.linePattern(expression)); + } + break; + case "line-gradient": + properties.add(PropertyFactory.lineGradient(expression)); + break; + case "line-cap": + properties.add(PropertyFactory.lineCap(expression)); + break; + case "line-join": + properties.add(PropertyFactory.lineJoin(expression)); + break; + case "line-miter-limit": + properties.add(PropertyFactory.lineMiterLimit(expression)); + break; + case "line-round-limit": + properties.add(PropertyFactory.lineRoundLimit(expression)); + break; + case "line-sort-key": + properties.add(PropertyFactory.lineSortKey(expression)); + break; + case "visibility": + properties.add(PropertyFactory.visibility(entry.getValue().substring(1, entry.getValue().length() - 1))); + break; + default: + break; + } + } + + return properties.toArray(new PropertyValue[properties.size()]); + } + + static PropertyValue[] interpretFillLayerProperties(Object o) { + final Map data = (Map) toMap(o); + final List properties = new LinkedList(); + final JsonParser parser = new JsonParser(); + + for (Map.Entry entry : data.entrySet()) { + final JsonElement jsonElement = parser.parse(entry.getValue()); + Expression expression = Expression.Converter.convert(jsonElement); + switch (entry.getKey()) { + case "fill-antialias": + properties.add(PropertyFactory.fillAntialias(expression)); + break; + case "fill-opacity": + properties.add(PropertyFactory.fillOpacity(expression)); + break; + case "fill-color": + properties.add(PropertyFactory.fillColor(expression)); + break; + case "fill-outline-color": + properties.add(PropertyFactory.fillOutlineColor(expression)); + break; + case "fill-translate": + if (jsonElement.isJsonArray()) { + final Float[] floatArray = convertJsonToFloatArray(jsonElement); + if (floatArray != null) { + properties.add(PropertyFactory.fillTranslate(floatArray)); + } else { + properties.add(PropertyFactory.fillTranslate(expression)); + } + } else { + properties.add(PropertyFactory.fillTranslate(expression)); + } + break; + case "fill-translate-anchor": + properties.add(PropertyFactory.fillTranslateAnchor(expression)); + break; + case "fill-pattern": + if (jsonElement.isJsonPrimitive() && jsonElement.getAsJsonPrimitive().isString()) { + properties.add(PropertyFactory.fillPattern(jsonElement.getAsString())); + } else { + properties.add(PropertyFactory.fillPattern(expression)); + } + break; + case "fill-sort-key": + properties.add(PropertyFactory.fillSortKey(expression)); + break; + case "visibility": + properties.add(PropertyFactory.visibility(entry.getValue().substring(1, entry.getValue().length() - 1))); + break; + default: + break; + } + } + + return properties.toArray(new PropertyValue[properties.size()]); + } + + static PropertyValue[] interpretFillExtrusionLayerProperties(Object o) { + final Map data = (Map) toMap(o); + final List properties = new LinkedList(); + final JsonParser parser = new JsonParser(); + + for (Map.Entry entry : data.entrySet()) { + final JsonElement jsonElement = parser.parse(entry.getValue()); + Expression expression = Expression.Converter.convert(jsonElement); + switch (entry.getKey()) { + case "fill-extrusion-opacity": + properties.add(PropertyFactory.fillExtrusionOpacity(expression)); + break; + case "fill-extrusion-color": + properties.add(PropertyFactory.fillExtrusionColor(expression)); + break; + case "fill-extrusion-translate": + if (jsonElement.isJsonArray()) { + final Float[] floatArray = convertJsonToFloatArray(jsonElement); + if (floatArray != null) { + properties.add(PropertyFactory.fillExtrusionTranslate(floatArray)); + } else { + properties.add(PropertyFactory.fillExtrusionTranslate(expression)); + } + } else { + properties.add(PropertyFactory.fillExtrusionTranslate(expression)); + } + break; + case "fill-extrusion-translate-anchor": + properties.add(PropertyFactory.fillExtrusionTranslateAnchor(expression)); + break; + case "fill-extrusion-pattern": + if (jsonElement.isJsonPrimitive() && jsonElement.getAsJsonPrimitive().isString()) { + properties.add(PropertyFactory.fillExtrusionPattern(jsonElement.getAsString())); + } else { + properties.add(PropertyFactory.fillExtrusionPattern(expression)); + } + break; + case "fill-extrusion-height": + properties.add(PropertyFactory.fillExtrusionHeight(expression)); + break; + case "fill-extrusion-base": + properties.add(PropertyFactory.fillExtrusionBase(expression)); + break; + case "fill-extrusion-vertical-gradient": + properties.add(PropertyFactory.fillExtrusionVerticalGradient(expression)); + break; + case "visibility": + properties.add(PropertyFactory.visibility(entry.getValue().substring(1, entry.getValue().length() - 1))); + break; + default: + break; + } + } + + return properties.toArray(new PropertyValue[properties.size()]); + } + + static PropertyValue[] interpretRasterLayerProperties(Object o) { + final Map data = (Map) toMap(o); + final List properties = new LinkedList(); + final JsonParser parser = new JsonParser(); + + for (Map.Entry entry : data.entrySet()) { + final JsonElement jsonElement = parser.parse(entry.getValue()); + Expression expression = Expression.Converter.convert(jsonElement); + switch (entry.getKey()) { + case "raster-opacity": + properties.add(PropertyFactory.rasterOpacity(expression)); + break; + case "raster-hue-rotate": + properties.add(PropertyFactory.rasterHueRotate(expression)); + break; + case "raster-brightness-min": + properties.add(PropertyFactory.rasterBrightnessMin(expression)); + break; + case "raster-brightness-max": + properties.add(PropertyFactory.rasterBrightnessMax(expression)); + break; + case "raster-saturation": + properties.add(PropertyFactory.rasterSaturation(expression)); + break; + case "raster-contrast": + properties.add(PropertyFactory.rasterContrast(expression)); + break; + case "raster-resampling": + properties.add(PropertyFactory.rasterResampling(expression)); + break; + case "raster-fade-duration": + properties.add(PropertyFactory.rasterFadeDuration(expression)); + break; + case "visibility": + properties.add(PropertyFactory.visibility(entry.getValue().substring(1, entry.getValue().length() - 1))); + break; + default: + break; + } + } + + return properties.toArray(new PropertyValue[properties.size()]); + } + + static PropertyValue[] interpretHillshadeLayerProperties(Object o) { + final Map data = (Map) toMap(o); + final List properties = new LinkedList(); + final JsonParser parser = new JsonParser(); + + for (Map.Entry entry : data.entrySet()) { + final JsonElement jsonElement = parser.parse(entry.getValue()); + Expression expression = Expression.Converter.convert(jsonElement); + switch (entry.getKey()) { + case "hillshade-illumination-direction": + properties.add(PropertyFactory.hillshadeIlluminationDirection(expression)); + break; + case "hillshade-illumination-anchor": + properties.add(PropertyFactory.hillshadeIlluminationAnchor(expression)); + break; + case "hillshade-exaggeration": + properties.add(PropertyFactory.hillshadeExaggeration(expression)); + break; + case "hillshade-shadow-color": + properties.add(PropertyFactory.hillshadeShadowColor(expression)); + break; + case "hillshade-highlight-color": + properties.add(PropertyFactory.hillshadeHighlightColor(expression)); + break; + case "hillshade-accent-color": + properties.add(PropertyFactory.hillshadeAccentColor(expression)); + break; + case "visibility": + properties.add(PropertyFactory.visibility(entry.getValue().substring(1, entry.getValue().length() - 1))); + break; + default: + break; + } + } + + return properties.toArray(new PropertyValue[properties.size()]); + } + + static PropertyValue[] interpretHeatmapLayerProperties(Object o) { + final Map data = (Map) toMap(o); + final List properties = new LinkedList(); + final JsonParser parser = new JsonParser(); + + for (Map.Entry entry : data.entrySet()) { + final JsonElement jsonElement = parser.parse(entry.getValue()); + Expression expression = Expression.Converter.convert(jsonElement); + switch (entry.getKey()) { + case "heatmap-radius": + properties.add(PropertyFactory.heatmapRadius(expression)); + break; + case "heatmap-weight": + properties.add(PropertyFactory.heatmapWeight(expression)); + break; + case "heatmap-intensity": + properties.add(PropertyFactory.heatmapIntensity(expression)); + break; + case "heatmap-color": + properties.add(PropertyFactory.heatmapColor(expression)); + break; + case "heatmap-opacity": + properties.add(PropertyFactory.heatmapOpacity(expression)); + break; + case "visibility": + properties.add(PropertyFactory.visibility(entry.getValue().substring(1, entry.getValue().length() - 1))); + break; + default: + break; + } + } + + return properties.toArray(new PropertyValue[properties.size()]); + } + + private static boolean isNumber(JsonElement element) { + return element.isJsonPrimitive() && element.getAsJsonPrimitive().isNumber(); + } + + private static Float[] convertJsonToFloatArray(JsonElement jsonElement) { + final JsonArray jsonArray = jsonElement.getAsJsonArray(); + Float[] floatArray = new Float[jsonArray.size()]; + + for (int i = 0; i < jsonArray.size(); i++) { + if (jsonArray.get(i).isJsonPrimitive() && jsonArray.get(i).getAsJsonPrimitive().isNumber()) { + floatArray[i] = jsonArray.get(i).getAsFloat(); + } else { + return null; + } + } + return floatArray; + } +} \ No newline at end of file diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/LocationEngineFactory.kt b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/LocationEngineFactory.kt new file mode 100644 index 00000000..766b865b --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/LocationEngineFactory.kt @@ -0,0 +1,38 @@ +package org.maplibre.maplibregl + +import android.content.Context +import org.maplibre.android.location.LocationComponent +import org.maplibre.android.location.engine.LocationEngine +import org.maplibre.android.location.engine.LocationEngineDefault.getDefaultLocationEngine +import org.maplibre.android.location.engine.LocationEngineProxy +import org.maplibre.android.location.engine.LocationEngineRequest + +class LocationEngineFactory { + + private var locationEngineRequest: LocationEngineRequest? = null + + fun getLocationEngine(context: Context): LocationEngine { + if (locationEngineRequest?.priority == LocationEngineRequest.PRIORITY_HIGH_ACCURACY) { + return LocationEngineProxy( + MapLibreGPSLocationEngine(context) + ) + } + return getDefaultLocationEngine(context) + } + + fun initLocationComponent( + context: Context, + locationComponent: LocationComponent?, + locationEngineRequest: LocationEngineRequest? + ) { + if (locationEngineRequest != null) { + this.locationEngineRequest = locationEngineRequest + } + if (locationComponent != null) { + locationComponent.locationEngine = getLocationEngine(context) + locationEngineRequest?.let { locationEngineRequest -> + locationComponent.locationEngineRequest = locationEngineRequest + } + } + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreCustomHttpInterceptor.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreCustomHttpInterceptor.java new file mode 100644 index 00000000..69684a2a --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreCustomHttpInterceptor.java @@ -0,0 +1,74 @@ +package org.maplibre.maplibregl; + +import org.maplibre.android.module.http.HttpRequestUtil; +import io.flutter.plugin.common.MethodChannel; +import java.util.Map; +import java.util.HashMap; +import java.util.List; +import java.util.ArrayList; +import java.util.regex.Pattern; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import android.util.Log; + +public class MapLibreCustomHttpInterceptor { + private static final String TAG = "MapLibreCustomHttpInterceptor"; + public static final HashMap CustomHeaders = new HashMap<>(); + public static final List Filter = new ArrayList<>(); + + public static void setCustomHeaders(Map headers, List filter, MethodChannel.Result result) { + CustomHeaders.clear(); + Filter.clear(); + + for (Map.Entry entry : headers.entrySet()) { + CustomHeaders.put(entry.getKey(), entry.getValue()); + Log.d(TAG, "Setting " + entry.getKey() + " to " + entry.getValue()); + } + + for (String pattern : filter) { + Filter.add(pattern); + } + + HttpRequestUtil.setOkHttpClient(getOkHttpClient().build()); + result.success(null); + } + + private static OkHttpClient.Builder getOkHttpClient() { + try { + return new OkHttpClient.Builder() + .addNetworkInterceptor( + chain -> { + Request.Builder builder = chain.request().newBuilder(); + String url = chain.request().url().toString(); + + // Check if URL matches any filter pattern + boolean shouldApplyHeaders = Filter.isEmpty(); + for (String pattern : Filter) { + if (Pattern.matches(pattern, url)) { + shouldApplyHeaders = true; + break; + } + } + + if (shouldApplyHeaders) { + for (Map.Entry header : CustomHeaders.entrySet()) { + if (header.getKey() == null || header.getKey().trim().isEmpty()) { + continue; + } + if (header.getValue() == null || header.getValue().trim().isEmpty()) { + builder.removeHeader(header.getKey()); + } else { + builder.header(header.getKey(), header.getValue()); + } + } + } + + return chain.proceed(builder.build()); + }); + } catch (Exception e) { + Log.e(TAG, "Error creating HTTP client: " + e.getMessage()); + throw new RuntimeException(e); + } + } +} + diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreGPSLocationEngine.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreGPSLocationEngine.java new file mode 100644 index 00000000..2dba9674 --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreGPSLocationEngine.java @@ -0,0 +1,140 @@ +package org.maplibre.maplibregl; + +import android.annotation.SuppressLint; +import android.app.PendingIntent; +import android.content.Context; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.os.Bundle; +import android.os.Looper; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import org.maplibre.android.location.engine.LocationEngineCallback; +import org.maplibre.android.location.engine.LocationEngineRequest; +import org.maplibre.android.location.engine.LocationEngineResult; +import org.maplibre.android.location.engine.LocationEngineImpl; + + +public class MapLibreGPSLocationEngine implements LocationEngineImpl { + private static final String TAG = "GPSLocationEngine"; + final LocationManager locationManager; + + String currentProvider = LocationManager.PASSIVE_PROVIDER; + + public MapLibreGPSLocationEngine(@NonNull Context context) { + locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE); + } + + @NonNull + @Override + public LocationListener createListener(LocationEngineCallback callback) { + return new AndroidLocationEngineCallbackTransport(callback); + } + + @Override + public void getLastLocation(@NonNull LocationEngineCallback callback) + throws SecurityException { + Location lastLocation = getLastLocationFor(currentProvider); + if (lastLocation != null) { + callback.onSuccess(LocationEngineResult.create(lastLocation)); + return; + } + + for (String provider : locationManager.getAllProviders()) { + lastLocation = getLastLocationFor(provider); + if (lastLocation != null) { + callback.onSuccess(LocationEngineResult.create(lastLocation)); + return; + } + } + callback.onFailure(new Exception("Last location unavailable")); + } + + @SuppressLint("MissingPermission") + Location getLastLocationFor(String provider) throws SecurityException { + Location location = null; + try { + location = locationManager.getLastKnownLocation(provider); + } catch (IllegalArgumentException iae) { + Log.e(TAG, iae.toString()); + } + return location; + } + + @SuppressLint("MissingPermission") + @Override + public void requestLocationUpdates(@NonNull LocationEngineRequest request, + @NonNull LocationListener listener, + @Nullable Looper looper) throws SecurityException { + currentProvider = getBestProvider(request.getPriority()); + locationManager.requestLocationUpdates(currentProvider, request.getInterval(), request.getDisplacement(), + listener, looper); + } + + @SuppressLint("MissingPermission") + @Override + public void requestLocationUpdates(@NonNull LocationEngineRequest request, + @NonNull PendingIntent pendingIntent) throws SecurityException { + currentProvider = getBestProvider(request.getPriority()); + locationManager.requestLocationUpdates(currentProvider, request.getInterval(), + request.getDisplacement(), pendingIntent); + } + + @SuppressLint("MissingPermission") + @Override + public void removeLocationUpdates(@NonNull LocationListener listener) { + if (listener != null) { + locationManager.removeUpdates(listener); + } + } + + @Override + public void removeLocationUpdates(PendingIntent pendingIntent) { + if (pendingIntent != null) { + locationManager.removeUpdates(pendingIntent); + } + } + + private String getBestProvider(int priority) { + String provider = null; + if (priority != LocationEngineRequest.PRIORITY_NO_POWER) { + provider = LocationManager.GPS_PROVIDER; + } + return provider != null ? provider : LocationManager.PASSIVE_PROVIDER; + } + + + @VisibleForTesting + static final class AndroidLocationEngineCallbackTransport implements LocationListener { + private final LocationEngineCallback callback; + + AndroidLocationEngineCallbackTransport(LocationEngineCallback callback) { + this.callback = callback; + } + + @Override + public void onLocationChanged(Location location) { + callback.onSuccess(LocationEngineResult.create(location)); + } + + @Override + public void onStatusChanged(String s, int i, Bundle bundle) { + // noop + } + + @Override + public void onProviderEnabled(String s) { + // noop + } + + @Override + public void onProviderDisabled(String s) { + callback.onFailure(new Exception("Current provider disabled")); + } + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreHttpRequestUtil.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreHttpRequestUtil.java new file mode 100644 index 00000000..5a796e6f --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreHttpRequestUtil.java @@ -0,0 +1,43 @@ +package org.maplibre.maplibregl; + +import org.maplibre.android.module.http.HttpRequestUtil; +import io.flutter.plugin.common.MethodChannel; +import java.util.Map; +import okhttp3.OkHttpClient; +import okhttp3.Request; + +abstract class MapLibreHttpRequestUtil { + + public static void setHttpHeaders(Map headers, MethodChannel.Result result) { + HttpRequestUtil.setOkHttpClient(getOkHttpClient(headers, result).build()); + result.success(null); + } + + private static OkHttpClient.Builder getOkHttpClient( + Map headers, MethodChannel.Result result) { + try { + return new OkHttpClient.Builder() + .addNetworkInterceptor( + chain -> { + Request.Builder builder = chain.request().newBuilder(); + for (Map.Entry header : headers.entrySet()) { + if (header.getKey() == null || header.getKey().trim().isEmpty()) { + continue; + } + if (header.getValue() == null || header.getValue().trim().isEmpty()) { + builder.removeHeader(header.getKey()); + } else { + builder.header(header.getKey(), header.getValue()); + } + } + return chain.proceed(builder.build()); + }); + } catch (Exception e) { + result.error( + "OK_HTTP_CLIENT_ERROR", + "An unexcepted error happened during creating http " + "client" + e.getMessage(), + null); + throw new RuntimeException(e); + } + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapBuilder.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapBuilder.java new file mode 100644 index 00000000..98cc2dad --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapBuilder.java @@ -0,0 +1,253 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package org.maplibre.maplibregl; + +import android.content.Context; +import android.view.Gravity; +import androidx.annotation.NonNull; +import org.maplibre.android.camera.CameraPosition; +import org.maplibre.android.geometry.LatLngBounds; +import org.maplibre.android.location.engine.LocationEngineRequest; +import org.maplibre.android.maps.MapLibreMapOptions; +import io.flutter.plugin.common.BinaryMessenger; + +class MapLibreMapBuilder implements MapLibreMapOptionsSink { + public final String TAG = getClass().getSimpleName(); + private final MapLibreMapOptions options = + new MapLibreMapOptions().attributionEnabled(true).logoEnabled(false).textureMode(true); + private boolean trackCameraPosition = false; + private boolean myLocationEnabled = false; + private boolean dragEnabled = true; + private int myLocationTrackingMode = 0; + private int myLocationRenderMode = 0; + private String styleString = ""; + private LatLngBounds bounds = null; + private LocationEngineRequest locationEngineRequest = null; + + MapLibreMapController build( + int id, + Context context, + BinaryMessenger messenger, + MapLibreMapsPlugin.LifecycleProvider lifecycleProvider) { + + final MapLibreMapController controller = + new MapLibreMapController( + id, context, messenger, lifecycleProvider, options, styleString, dragEnabled); + controller.init(); + controller.setMyLocationEnabled(myLocationEnabled); + controller.setMyLocationTrackingMode(myLocationTrackingMode); + controller.setMyLocationRenderMode(myLocationRenderMode); + controller.setTrackCameraPosition(trackCameraPosition); + + if (null != bounds) { + controller.setCameraTargetBounds(bounds); + } + + if(null != locationEngineRequest ){ + controller.setLocationEngineProperties(locationEngineRequest); + } + + return controller; + } + + public void setInitialCameraPosition(CameraPosition position) { + options.camera(position); + } + + @Override + public void setCompassEnabled(boolean compassEnabled) { + options.compassEnabled(compassEnabled); + } + + @Override + public void setCameraTargetBounds(@NonNull LatLngBounds bounds) { + this.bounds = bounds; + } + + @Override + public void setStyleString(@NonNull String styleString) { + this.styleString = styleString; + } + + @Override + public void setMinMaxZoomPreference(Float min, Float max) { + if (min != null) { + options.minZoomPreference(min); + } + if (max != null) { + options.maxZoomPreference(max); + } + } + + @Override + public void setTrackCameraPosition(boolean trackCameraPosition) { + this.trackCameraPosition = trackCameraPosition; + } + + @Override + public void setRotateGesturesEnabled(boolean rotateGesturesEnabled) { + options.rotateGesturesEnabled(rotateGesturesEnabled); + } + + @Override + public void setScrollGesturesEnabled(boolean scrollGesturesEnabled) { + options.scrollGesturesEnabled(scrollGesturesEnabled); + } + + @Override + public void setTiltGesturesEnabled(boolean tiltGesturesEnabled) { + options.tiltGesturesEnabled(tiltGesturesEnabled); + } + + @Override + public void setZoomGesturesEnabled(boolean zoomGesturesEnabled) { + options.zoomGesturesEnabled(zoomGesturesEnabled); + } + + @Override + public void setMyLocationEnabled(boolean myLocationEnabled) { + this.myLocationEnabled = myLocationEnabled; + } + + @Override + public void setMyLocationTrackingMode(int myLocationTrackingMode) { + this.myLocationTrackingMode = myLocationTrackingMode; + } + + @Override + public void setMyLocationRenderMode(int myLocationRenderMode) { + this.myLocationRenderMode = myLocationRenderMode; + } + + @Override + public void setLogoEnabled(boolean logoEnabled) { + options.logoEnabled(logoEnabled); + } + + @Override + public void setLogoViewGravity(int gravity) { + switch (gravity) { + case 0: + options.logoGravity(Gravity.TOP | Gravity.START); + break; + case 1: + options.logoGravity(Gravity.TOP | Gravity.END); + break; + case 2: + options.logoGravity(Gravity.BOTTOM | Gravity.START); + break; + case 3: + options.logoGravity(Gravity.BOTTOM | Gravity.END); + break; + } + } + + public void setLogoViewMargins(int x, int y) { + options.logoMargins( + new int[] { + (int) x, // left + (int) 0, // top + (int) 0, // right + (int) y, // bottom + }); + } + + @Override + public void setCompassGravity(int gravity) { + switch (gravity) { + case 0: + options.compassGravity(Gravity.TOP | Gravity.START); + break; + case 1: + options.compassGravity(Gravity.TOP | Gravity.END); + break; + case 2: + options.compassGravity(Gravity.BOTTOM | Gravity.START); + break; + case 3: + options.compassGravity(Gravity.BOTTOM | Gravity.END); + break; + } + } + + @Override + public void setCompassViewMargins(int x, int y) { + switch (options.getCompassGravity()) { + case Gravity.TOP | Gravity.START: + options.compassMargins(new int[] {(int) x, (int) y, 0, 0}); + break; + // If the application code has not specified gravity, assume the platform + // default for the compass which is top-right + default: + case Gravity.TOP | Gravity.END: + options.compassMargins(new int[] {0, (int) y, (int) x, 0}); + break; + case Gravity.BOTTOM | Gravity.START: + options.compassMargins(new int[] {(int) x, 0, 0, (int) y}); + break; + case Gravity.BOTTOM | Gravity.END: + options.compassMargins(new int[] {0, 0, (int) x, (int) y}); + break; + } + } + + @Override + public void setAttributionButtonGravity(int gravity) { + switch (gravity) { + case 0: + options.attributionGravity(Gravity.TOP | Gravity.START); + break; + case 1: + options.attributionGravity(Gravity.TOP | Gravity.END); + break; + case 2: + options.attributionGravity(Gravity.BOTTOM | Gravity.START); + break; + case 3: + options.attributionGravity(Gravity.BOTTOM | Gravity.END); + break; + } + } + + @Override + public void setAttributionButtonMargins(int x, int y) { + switch (options.getAttributionGravity()) { + case Gravity.TOP | Gravity.START: + options.attributionMargins(new int[] {(int) x, (int) y, 0, 0}); + break; + case Gravity.TOP | Gravity.END: + options.attributionMargins(new int[] {0, (int) y, (int) x, 0}); + break; + // If the application code has not specified gravity, assume the platform + // default for the attribution button which is bottom left + default: + case Gravity.BOTTOM | Gravity.START: + options.attributionMargins(new int[] {(int) x, 0, 0, (int) y}); + break; + case Gravity.BOTTOM | Gravity.END: + options.attributionMargins(new int[] {0, 0, (int) x, (int) y}); + break; + } + } + + public void setDragEnabled(boolean enabled) { + this.dragEnabled = enabled; + } + + @Override + public void setLocationEngineProperties(@NonNull LocationEngineRequest locationEngineRequest) { + this.locationEngineRequest = locationEngineRequest; + } + + @Override + public void setForegroundLoadColor(int loadColor) { + options.foregroundLoadColor(loadColor); + } + + @Override + public void setTranslucentTextureSurface(boolean translucentTextureSurface) { + options.translucentTextureSurface(translucentTextureSurface); + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapController.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapController.java new file mode 100644 index 00000000..e26dbfb3 --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapController.java @@ -0,0 +1,2555 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package org.maplibre.maplibregl; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.pm.PackageManager; +import android.content.res.AssetFileDescriptor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.PointF; +import android.graphics.RectF; +import android.location.Location; +import android.os.Build; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.Pair; +import android.view.Gravity; +import android.view.MotionEvent; +import android.view.TextureView; +import android.view.View; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleOwner; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; + +import org.jetbrains.annotations.NotNull; +import org.maplibre.android.camera.CameraPosition; +import org.maplibre.android.camera.CameraUpdate; +import org.maplibre.android.camera.CameraUpdateFactory; +import org.maplibre.android.constants.MapLibreConstants; +import org.maplibre.android.geometry.LatLng; +import org.maplibre.android.geometry.LatLngBounds; +import org.maplibre.android.geometry.LatLngQuad; +import org.maplibre.android.geometry.VisibleRegion; +import org.maplibre.android.gestures.AndroidGesturesManager; +import org.maplibre.android.gestures.MoveGestureDetector; +import org.maplibre.android.location.LocationComponent; +import org.maplibre.android.location.LocationComponentActivationOptions; +import org.maplibre.android.location.LocationComponentOptions; +import org.maplibre.android.location.OnCameraTrackingChangedListener; +import org.maplibre.android.location.engine.LocationEngineCallback; +import org.maplibre.android.location.engine.LocationEngineRequest; +import org.maplibre.android.location.engine.LocationEngineResult; +import org.maplibre.android.location.modes.CameraMode; +import org.maplibre.android.location.modes.RenderMode; +import org.maplibre.android.maps.MapLibreMap; +import org.maplibre.android.maps.MapLibreMapOptions; +import org.maplibre.android.maps.MapView; +import org.maplibre.android.maps.OnMapReadyCallback; +import org.maplibre.android.maps.Style; +import org.maplibre.android.offline.OfflineManager; +import org.maplibre.android.style.expressions.Expression; +import org.maplibre.android.style.layers.CircleLayer; +import org.maplibre.android.style.layers.FillExtrusionLayer; +import org.maplibre.android.style.layers.FillLayer; +import org.maplibre.android.style.layers.HeatmapLayer; +import org.maplibre.android.style.layers.HillshadeLayer; +import org.maplibre.android.style.layers.Layer; +import org.maplibre.android.style.layers.LineLayer; +import org.maplibre.android.style.layers.Property; +import org.maplibre.android.style.layers.PropertyFactory; +import org.maplibre.android.style.layers.PropertyValue; +import org.maplibre.android.style.layers.RasterLayer; +import org.maplibre.android.style.layers.SymbolLayer; +import org.maplibre.android.style.sources.CustomGeometrySource; +import org.maplibre.android.style.sources.GeoJsonSource; +import org.maplibre.android.style.sources.ImageSource; +import org.maplibre.android.style.sources.Source; +import org.maplibre.android.style.sources.VectorSource; +import org.maplibre.geojson.Feature; +import org.maplibre.geojson.FeatureCollection; +import org.maplibre.android.net.ConnectivityReceiver; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.platform.PlatformView; + + +/** Controller of a single MapLibreMaps MapView instance. */ +@SuppressLint("MissingPermission") +final class MapLibreMapController + implements DefaultLifecycleObserver, + MapLibreMap.OnCameraIdleListener, + MapLibreMap.OnCameraMoveListener, + MapLibreMap.OnCameraMoveStartedListener, + MapView.OnDidBecomeIdleListener, + MapLibreMap.OnMapClickListener, + MapLibreMap.OnMapLongClickListener, + MapLibreMapOptionsSink, + MethodChannel.MethodCallHandler, + OnMapReadyCallback, + OnCameraTrackingChangedListener, + PlatformView { + private static final String TAG = "MapLibreMapController"; + private final int id; + private final MethodChannel methodChannel; + private final MapLibreMapsPlugin.LifecycleProvider lifecycleProvider; + private final float density; + private final Context context; + private final String styleStringInitial; + /** + * This container is returned as the final platform view instead of returning `mapView`. + * See {@link MapLibreMapController#destroyMapViewIfNecessary()} for details. + */ + private FrameLayout mapViewContainer; + private MapView mapView; + private MapLibreMap mapLibreMap; + private boolean trackCameraPosition = false; + private boolean myLocationEnabled = false; + private int myLocationTrackingMode = 0; + private int myLocationRenderMode = 0; + private LocationEngineFactory myLocationEngineFactory = new LocationEngineFactory(); + private boolean disposed = false; + private boolean dragEnabled = true; + private MethodChannel.Result mapReadyResult; + private LocationComponent locationComponent = null; + private LocationEngineCallback locationEngineCallback = null; + private Style style; + private Feature draggedFeature; + private AndroidGesturesManager androidGesturesManager; + + private LatLng dragOrigin; + private LatLng dragPrevious; + + private Set interactiveFeatureLayerIds; + private Map addedFeaturesByLayer; + + private LatLngBounds bounds = null; + Style.OnStyleLoaded onStyleLoadedCallback = + new Style.OnStyleLoaded() { + @Override + public void onStyleLoaded(@NonNull Style style) { + MapLibreMapController.this.style = style; + + // commented out while cherry-picking upstream956 + // if (myLocationEnabled) { + // if (hasLocationPermission()) { + // updateMyLocationEnabled(); + // } + // } + updateMyLocationEnabled(); + + if (null != bounds) { + setCameraTargetBounds(bounds); + } + + mapLibreMap.addOnMapClickListener(MapLibreMapController.this); + mapLibreMap.addOnMapLongClickListener(MapLibreMapController.this); + + methodChannel.invokeMethod("map#onStyleLoaded", null); + } + }; + + MapLibreMapController( + int id, + Context context, + BinaryMessenger messenger, + MapLibreMapsPlugin.LifecycleProvider lifecycleProvider, + MapLibreMapOptions options, + String styleStringInitial, + boolean dragEnabled) { + MapLibreUtils.getMapLibre(context); + this.id = id; + this.context = context; + this.dragEnabled = dragEnabled; + this.styleStringInitial = styleStringInitial; + this.mapViewContainer = new FrameLayout(context); + this.mapView = new MapView(context, options); + this.interactiveFeatureLayerIds = new HashSet<>(); + this.addedFeaturesByLayer = new HashMap(); + this.density = context.getResources().getDisplayMetrics().density; + this.lifecycleProvider = lifecycleProvider; + if (dragEnabled) { + this.androidGesturesManager = new AndroidGesturesManager(this.mapView.getContext(), false); + } + + mapViewContainer.addView(mapView); + methodChannel = new MethodChannel(messenger, "plugins.flutter.io/maplibre_gl_" + id); + methodChannel.setMethodCallHandler(this); + } + + @Override + public View getView() { + return mapViewContainer; + } + + void init() { + lifecycleProvider.getLifecycle().addObserver(this); + mapView.getMapAsync(this); + } + + private void moveCamera(CameraUpdate cameraUpdate) { + mapLibreMap.moveCamera(cameraUpdate); + } + + private void animateCamera(CameraUpdate cameraUpdate) { + mapLibreMap.animateCamera(cameraUpdate); + } + + private CameraPosition getCameraPosition() { + return trackCameraPosition ? mapLibreMap.getCameraPosition() : null; + } + + @Override + public void onMapReady(MapLibreMap mapLibreMap) { + this.mapLibreMap = mapLibreMap; + if (mapReadyResult != null) { + mapReadyResult.success(null); + mapReadyResult = null; + } + mapLibreMap.addOnCameraMoveStartedListener(this); + mapLibreMap.addOnCameraMoveListener(this); + mapLibreMap.addOnCameraIdleListener(this); + + // Apply camera target bounds if set during initialization + if (bounds != null) { + mapLibreMap.setLatLngBoundsForCameraTarget(bounds); + } + + if (androidGesturesManager != null) { + androidGesturesManager.setMoveGestureListener(new MoveGestureListener()); + mapView.setOnTouchListener( + new View.OnTouchListener() { + @Override + public boolean onTouch(View v, MotionEvent event) { + androidGesturesManager.onTouchEvent(event); + + return draggedFeature != null; + } + }); + } + + mapView.addOnStyleImageMissingListener( + (id) -> { + DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics(); + final Bitmap bitmap = getScaledImage(id, displayMetrics.density); + if (bitmap != null) { + mapLibreMap.getStyle().addImage(id, bitmap); + } + }); + + mapView.addOnDidBecomeIdleListener(this); + + setStyleString(styleStringInitial); + } + + @Override + public void setStyleString(@NonNull String styleString) { + // clear old layer id from the location Component + clearLocationComponentLayer(); + styleString = styleString.trim(); + + // Prevent race conditions: invalidate current style reference & interactive layers + // Old Style instances become invalid immediately after setStyle is called. + this.style = null; + if (interactiveFeatureLayerIds != null) { + interactiveFeatureLayerIds.clear(); + } + + // Check if json, url, absolute path or asset path: + if (styleString == null || styleString.isEmpty()) { + Log.e(TAG, "setStyleString - string empty or null"); + } else if (styleString.startsWith("{") || styleString.startsWith("[")) { + mapLibreMap.setStyle(new Style.Builder().fromJson(styleString), onStyleLoadedCallback); + } else if (styleString.startsWith("/")) { + // Absolute path + mapLibreMap.setStyle( + new Style.Builder().fromUri("file://" + styleString), onStyleLoadedCallback); + } else if (!styleString.startsWith("http://") + && !styleString.startsWith("https://") + && !styleString.startsWith("mapbox://")) { + // We are assuming that the style will be loaded from an asset here. + String key = MapLibreMapsPlugin.flutterAssets.getAssetFilePathByName(styleString); + mapLibreMap.setStyle(new Style.Builder().fromUri("asset://" + key), onStyleLoadedCallback); + } else { + mapLibreMap.setStyle(new Style.Builder().fromUri(styleString), onStyleLoadedCallback); + } + } + + + + @SuppressWarnings({"MissingPermission"}) + private void enableLocationComponent(@NonNull Style style) { + if (hasLocationPermission()) { + + locationComponent = mapLibreMap.getLocationComponent(); + + LocationComponentActivationOptions options = + LocationComponentActivationOptions + .builder(context, style) + .locationComponentOptions(buildLocationComponentOptions(style)) + .locationEngine(myLocationEngineFactory.getLocationEngine(context)) + .build(); + + locationComponent.activateLocationComponent(options); + locationComponent.setLocationComponentEnabled(true); + locationComponent.setMaxAnimationFps(30); + updateMyLocationTrackingMode(); + updateMyLocationRenderMode(); + locationComponent.addOnCameraTrackingChangedListener(this); + } else { + Log.e(TAG, "missing location permissions"); + } + } + + private void updateLocationComponentLayer() { + if (locationComponent != null && locationComponentRequiresUpdate()) { + locationComponent.applyStyle(buildLocationComponentOptions(style)); + } + } + + private void clearLocationComponentLayer() { + if (locationComponent != null) { + locationComponent.applyStyle(buildLocationComponentOptions(null)); + } + } + + String getLastLayerOnStyle(Style style) { + if (style == null) return null; + if (!style.isFullyLoaded()) { + Log.d(TAG, "getLastLayerOnStyle: style not fully loaded yet"); + return null; + } + + final List layers = style.getLayers(); + if (layers.size() > 0) { + return layers.get(layers.size() - 1).getId(); + } + + return null; + } + + /// only update if the last layer is not the mapbox-location-bearing-layer + boolean locationComponentRequiresUpdate() { + final String lastLayerId = getLastLayerOnStyle(style); + return lastLayerId != null && !lastLayerId.equals("mapbox-location-bearing-layer"); + } + + private LocationComponentOptions buildLocationComponentOptions(Style style) { + final LocationComponentOptions.Builder optionsBuilder = + LocationComponentOptions.builder(context); + optionsBuilder.trackingGesturesManagement(true); + + final String lastLayerId = getLastLayerOnStyle(style); + if (lastLayerId != null) { + optionsBuilder.layerAbove(lastLayerId); + } + return optionsBuilder.build(); + } + + private void onUserLocationUpdate(Location location) { + if (location == null) { + return; + } + + final Map userLocation = new HashMap<>(6); + userLocation.put("position", new double[] {location.getLatitude(), location.getLongitude()}); + userLocation.put("speed", location.getSpeed()); + userLocation.put("altitude", location.getAltitude()); + userLocation.put("bearing", location.getBearing()); + userLocation.put("speed", location.getSpeed()); + userLocation.put("horizontalAccuracy", location.getAccuracy()); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + userLocation.put( + "verticalAccuracy", + (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + ? location.getVerticalAccuracyMeters() + : null); + } + userLocation.put("timestamp", location.getTime()); + + final Map arguments = new HashMap<>(1); + arguments.put("userLocation", userLocation); + methodChannel.invokeMethod("map#onUserLocationUpdated", arguments); + } + + private void addGeoJsonSource(String sourceName, String source) { + FeatureCollection featureCollection = FeatureCollection.fromJson(source); + GeoJsonSource geoJsonSource = new GeoJsonSource(sourceName, featureCollection); + addedFeaturesByLayer.put(sourceName, featureCollection); + + style.addSource(geoJsonSource); + } + + private void setGeoJsonSource(String sourceName, String geojson) { + FeatureCollection featureCollection = FeatureCollection.fromJson(geojson); + GeoJsonSource geoJsonSource = style.getSourceAs(sourceName); + addedFeaturesByLayer.put(sourceName, featureCollection); + + geoJsonSource.setGeoJson(featureCollection); + } + + private void setGeoJsonFeature(String sourceName, String geojsonFeature) { + Feature feature = Feature.fromJson(geojsonFeature); + FeatureCollection featureCollection = addedFeaturesByLayer.get(sourceName); + GeoJsonSource geoJsonSource = style.getSourceAs(sourceName); + if (featureCollection != null && geoJsonSource != null) { + final List features = featureCollection.features(); + for (int i = 0; i < features.size(); i++) { + final String id = features.get(i).id(); + if (id.equals(feature.id())) { + features.set(i, feature); + break; + } + } + + geoJsonSource.setGeoJson(featureCollection); + } + } + + private void addSymbolLayer( + String layerName, + String sourceName, + String belowLayerId, + String sourceLayer, + Float minZoom, + Float maxZoom, + PropertyValue[] properties, + boolean enableInteraction, + Expression filter) { + SymbolLayer symbolLayer = new SymbolLayer(layerName, sourceName); + symbolLayer.setProperties(properties); + if (sourceLayer != null) { + symbolLayer.setSourceLayer(sourceLayer); + } + if (minZoom != null) { + symbolLayer.setMinZoom(minZoom); + } + if (maxZoom != null) { + symbolLayer.setMaxZoom(maxZoom); + } + if (filter != null) { + symbolLayer.setFilter(filter); + } + if (belowLayerId != null) { + style.addLayerBelow(symbolLayer, belowLayerId); + } else { + style.addLayer(symbolLayer); + } + if (enableInteraction) { + interactiveFeatureLayerIds.add(layerName); + } + } + + private void addLineLayer( + String layerName, + String sourceName, + String belowLayerId, + String sourceLayer, + Float minZoom, + Float maxZoom, + PropertyValue[] properties, + boolean enableInteraction, + Expression filter) { + LineLayer lineLayer = new LineLayer(layerName, sourceName); + lineLayer.setProperties(properties); + if (sourceLayer != null) { + lineLayer.setSourceLayer(sourceLayer); + } + if (minZoom != null) { + lineLayer.setMinZoom(minZoom); + } + if (maxZoom != null) { + lineLayer.setMaxZoom(maxZoom); + } + if (filter != null) { + lineLayer.setFilter(filter); + } + if (belowLayerId != null) { + style.addLayerBelow(lineLayer, belowLayerId); + } else { + style.addLayer(lineLayer); + } + if (enableInteraction) { + interactiveFeatureLayerIds.add(layerName); + } + } + + private void addFillLayer( + String layerName, + String sourceName, + String belowLayerId, + String sourceLayer, + Float minZoom, + Float maxZoom, + PropertyValue[] properties, + boolean enableInteraction, + Expression filter) { + FillLayer fillLayer = new FillLayer(layerName, sourceName); + fillLayer.setProperties(properties); + if (sourceLayer != null) { + fillLayer.setSourceLayer(sourceLayer); + } + if (minZoom != null) { + fillLayer.setMinZoom(minZoom); + } + if (maxZoom != null) { + fillLayer.setMaxZoom(maxZoom); + } + if (filter != null) { + fillLayer.setFilter(filter); + } + if (belowLayerId != null) { + style.addLayerBelow(fillLayer, belowLayerId); + } else { + style.addLayer(fillLayer); + } + if (enableInteraction) { + interactiveFeatureLayerIds.add(layerName); + } + } + + private void addFillExtrusionLayer( + String layerName, + String sourceName, + String belowLayerId, + String sourceLayer, + Float minZoom, + Float maxZoom, + PropertyValue[] properties, + boolean enableInteraction, + Expression filter) { + FillExtrusionLayer fillLayer = new FillExtrusionLayer(layerName, sourceName); + fillLayer.setProperties(properties); + if (sourceLayer != null) { + fillLayer.setSourceLayer(sourceLayer); + } + if (minZoom != null) { + fillLayer.setMinZoom(minZoom); + } + if (maxZoom != null) { + fillLayer.setMaxZoom(maxZoom); + } + if (filter != null) { + fillLayer.setFilter(filter); + } + if (belowLayerId != null) { + style.addLayerBelow(fillLayer, belowLayerId); + } else { + style.addLayer(fillLayer); + } + if (enableInteraction) { + interactiveFeatureLayerIds.add(layerName); + } + } + + private void addCircleLayer( + String layerName, + String sourceName, + String belowLayerId, + String sourceLayer, + Float minZoom, + Float maxZoom, + PropertyValue[] properties, + boolean enableInteraction, + Expression filter) { + CircleLayer circleLayer = new CircleLayer(layerName, sourceName); + circleLayer.setProperties(properties); + if (sourceLayer != null) { + circleLayer.setSourceLayer(sourceLayer); + } + if (minZoom != null) { + circleLayer.setMinZoom(minZoom); + } + if (maxZoom != null) { + circleLayer.setMaxZoom(maxZoom); + } + if (filter != null) { + circleLayer.setFilter(filter); + } + if (belowLayerId != null) { + style.addLayerBelow(circleLayer, belowLayerId); + } else { + style.addLayer(circleLayer); + } + if (enableInteraction) { + interactiveFeatureLayerIds.add(layerName); + } + } + + private Expression parseFilter(String filter) { + JsonParser parser = new JsonParser(); + JsonElement filterJsonElement = parser.parse(filter); + return filterJsonElement.isJsonNull() ? null : Expression.Converter.convert(filterJsonElement); + } + + private void addRasterLayer( + String layerName, + String sourceName, + Float minZoom, + Float maxZoom, + String belowLayerId, + PropertyValue[] properties, + Expression filter) { + RasterLayer layer = new RasterLayer(layerName, sourceName); + layer.setProperties(properties); + if (minZoom != null) { + layer.setMinZoom(minZoom); + } + if (maxZoom != null) { + layer.setMaxZoom(maxZoom); + } + if (belowLayerId != null) { + style.addLayerBelow(layer, belowLayerId); + } else { + style.addLayer(layer); + } + } + + private void addHillshadeLayer( + String layerName, + String sourceName, + Float minZoom, + Float maxZoom, + String belowLayerId, + PropertyValue[] properties, + Expression filter) { + HillshadeLayer layer = new HillshadeLayer(layerName, sourceName); + layer.setProperties(properties); + if (minZoom != null) { + layer.setMinZoom(minZoom); + } + if (maxZoom != null) { + layer.setMaxZoom(maxZoom); + } + if (belowLayerId != null) { + style.addLayerBelow(layer, belowLayerId); + } else { + style.addLayer(layer); + } + } + + private void addHeatmapLayer( + String layerName, + String sourceName, + Float minZoom, + Float maxZoom, + String belowLayerId, + PropertyValue[] properties, + Expression filter) { + HeatmapLayer layer = new HeatmapLayer(layerName, sourceName); + layer.setProperties(properties); + if (minZoom != null) { + layer.setMinZoom(minZoom); + } + if (maxZoom != null) { + layer.setMaxZoom(maxZoom); + } + if (belowLayerId != null) { + style.addLayerBelow(layer, belowLayerId); + } else { + style.addLayer(layer); + } + } + + private Pair firstFeatureOnLayers(RectF in) { + if (style == null) return null; + if (!style.isFullyLoaded()) { + Log.d(TAG, "firstFeatureOnLayers: style not fully loaded yet"); + return null; + } + + final List layers; + try { + layers = style.getLayers(); + } catch (IllegalStateException ex) { + // Style object is stale (a new style is loading/has loaded). Skip querying. + Log.w(TAG, "firstFeatureOnLayers: Style.getLayers() failed: " + ex.getMessage()); + return null; + } + final List layersInOrder = new ArrayList(); + for (Layer layer : layers) { + String id = layer.getId(); + if (interactiveFeatureLayerIds.contains(id)) layersInOrder.add(id); + } + Collections.reverse(layersInOrder); + for (String id : layersInOrder) { + List features = mapLibreMap.queryRenderedFeatures(in, id); + if (!features.isEmpty()) { + return new Pair(features.get(0), id); + } + } + + return null; + } + + @Override + public void onMethodCall(MethodCall call, MethodChannel.Result result) { + switch (call.method) { + case "map#waitForMap": + if (mapLibreMap != null) { + result.success(null); + return; + } + mapReadyResult = result; + break; + case "map#update": + { + Convert.interpretMapLibreMapOptions(call.argument("options"), this, context); + result.success(Convert.toJson(getCameraPosition())); + break; + } + case "map#updateMyLocationTrackingMode": + { + int myLocationTrackingMode = call.argument("mode"); + setMyLocationTrackingMode(myLocationTrackingMode); + result.success(null); + break; + } + case "map#matchMapLanguageWithDeviceDefault": + { + try { + final Locale deviceLocale = Locale.getDefault(); + MapLibreMapUtils.setMapLanguage(mapLibreMap, deviceLocale.getLanguage()); + + result.success(null); + } catch (RuntimeException exception) { + Log.d(TAG, exception.toString()); + result.error("MAPBOX LOCALIZATION PLUGIN ERROR", exception.toString(), null); + } + break; + } + case "map#updateContentInsets": + { + HashMap insets = call.argument("bounds"); + final CameraUpdate cameraUpdate = + CameraUpdateFactory.paddingTo( + Convert.toPixels(insets.get("left"), density), + Convert.toPixels(insets.get("top"), density), + Convert.toPixels(insets.get("right"), density), + Convert.toPixels(insets.get("bottom"), density)); + + if (call.argument("animated")) { + animateCamera(cameraUpdate, null, result); + } else { + moveCamera(cameraUpdate, result); + } + break; + } + case "map#setMapLanguage": + { + final String language = call.argument("language"); + try { + MapLibreMapUtils.setMapLanguage(mapLibreMap, language); + + result.success(null); + } catch (RuntimeException exception) { + Log.d(TAG, exception.toString()); + result.error("MAPBOX LOCALIZATION PLUGIN ERROR", exception.toString(), null); + } + break; + } + case "map#getVisibleRegion": + { + Map reply = new HashMap<>(); + VisibleRegion visibleRegion = mapLibreMap.getProjection().getVisibleRegion(); + reply.put( + "sw", + Arrays.asList( + visibleRegion.latLngBounds.getLatSouth(), visibleRegion.latLngBounds.getLonWest())); + reply.put( + "ne", + Arrays.asList( + visibleRegion.latLngBounds.getLatNorth(), visibleRegion.latLngBounds.getLonEast())); + + result.success(reply); + break; + } + case "map#toScreenLocation": + { + Map reply = new HashMap<>(); + PointF pointf = + mapLibreMap + .getProjection() + .toScreenLocation( + new LatLng(call.argument("latitude"), call.argument("longitude"))); + reply.put("x", pointf.x); + reply.put("y", pointf.y); + result.success(reply); + break; + } + case "map#toScreenLocationBatch": + { + double[] param = (double[]) call.argument("coordinates"); + double[] reply = new double[param.length]; + + for (int i = 0; i < param.length; i += 2) { + PointF pointf = + mapLibreMap.getProjection().toScreenLocation(new LatLng(param[i], param[i + 1])); + reply[i] = pointf.x; + reply[i + 1] = pointf.y; + } + + result.success(reply); + break; + } + case "map#toLatLng": + { + Map reply = new HashMap<>(); + LatLng latlng = + mapLibreMap + .getProjection() + .fromScreenLocation( + new PointF( + ((Double) call.argument("x")).floatValue(), + ((Double) call.argument("y")).floatValue())); + reply.put("latitude", latlng.getLatitude()); + reply.put("longitude", latlng.getLongitude()); + result.success(reply); + break; + } + case "map#getMetersPerPixelAtLatitude": + { + Map reply = new HashMap<>(); + Double retVal = + mapLibreMap + .getProjection() + .getMetersPerPixelAtLatitude((Double) call.argument("latitude")); + reply.put("metersperpixel", retVal); + result.success(reply); + break; + } + case "camera#move": + { + // MESHMAPPER GUARD: skip camera ops while the map view has no usable + // viewport. MapLibre's transform unprojects against the viewport and + // can abort with an uncatchable native error when it is + // degenerate/zero-sized. Complete the Future so Dart's await returns. + if (mapView == null || mapView.getWidth() < 1 || mapView.getHeight() < 1) { + result.success(false); + break; + } + final CameraUpdate cameraUpdate = + Convert.toCameraUpdate(call.argument("cameraUpdate"), mapLibreMap, density); + if (cameraUpdate != null) { + // camera transformation not handled yet + mapLibreMap.moveCamera( + cameraUpdate, + new OnCameraMoveFinishedListener() { + @Override + public void onFinish() { + super.onFinish(); + result.success(true); + } + + @Override + public void onCancel() { + super.onCancel(); + result.success(false); + } + }); + + // moveCamera(cameraUpdate); + } else { + result.success(false); + } + break; + } + case "camera#animate": + { + // MESHMAPPER GUARD: skip camera ops while the map view has no usable + // viewport (see camera#move) — prevents the uncatchable native abort. + if (mapView == null || mapView.getWidth() < 1 || mapView.getHeight() < 1) { + result.success(false); + break; + } + final CameraUpdate cameraUpdate = + Convert.toCameraUpdate(call.argument("cameraUpdate"), mapLibreMap, density); + final Integer duration = call.argument("duration"); + + final OnCameraMoveFinishedListener onCameraMoveFinishedListener = + new OnCameraMoveFinishedListener() { + @Override + public void onFinish() { + super.onFinish(); + result.success(true); + } + + @Override + public void onCancel() { + super.onCancel(); + result.success(false); + } + }; + if (cameraUpdate != null && duration != null) { + // camera transformation not handled yet + mapLibreMap.animateCamera(cameraUpdate, duration, onCameraMoveFinishedListener); + } else if (cameraUpdate != null) { + // camera transformation not handled yet + mapLibreMap.animateCamera(cameraUpdate, onCameraMoveFinishedListener); + } else { + result.success(false); + } + break; + } + case "map#queryRenderedFeatures": + { + Map reply = new HashMap<>(); + List features; + + String[] layerIds = ((List) call.argument("layerIds")).toArray(new String[0]); + + List filter = call.argument("filter"); + JsonElement jsonElement = filter == null ? null : new Gson().toJsonTree(filter); + JsonArray jsonArray = null; + if (jsonElement != null && jsonElement.isJsonArray()) { + jsonArray = jsonElement.getAsJsonArray(); + } + Expression filterExpression = + jsonArray == null ? null : Expression.Converter.convert(jsonArray); + if (call.hasArgument("x")) { + Double x = call.argument("x"); + Double y = call.argument("y"); + PointF pixel = new PointF(x.floatValue(), y.floatValue()); + features = mapLibreMap.queryRenderedFeatures(pixel, filterExpression, layerIds); + } else { + Double left = call.argument("left"); + Double top = call.argument("top"); + Double right = call.argument("right"); + Double bottom = call.argument("bottom"); + RectF rectF = + new RectF( + left.floatValue(), top.floatValue(), right.floatValue(), bottom.floatValue()); + features = mapLibreMap.queryRenderedFeatures(rectF, filterExpression, layerIds); + } + List featuresJson = new ArrayList<>(); + for (Feature feature : features) { + featuresJson.add(feature.toJson()); + } + reply.put("features", featuresJson); + result.success(reply); + break; + } + case "map#setTelemetryEnabled": + { + result.success(null); + break; + } + case "map#getTelemetryEnabled": + { + result.success(false); + break; + } + case "map#setMaximumFps": + { + final int fps = call.argument("fps"); + if (mapView != null) { + mapView.setMaximumFps(fps); + } + result.success(null); + break; + } + case "map#forceOnlineMode": + { + // Force online mode by setting connectivity to true + if (mapView != null) { + ConnectivityReceiver.instance(mapView.getContext()).setConnected(true); + } + result.success(null); + break; + } + case "camera#ease": + { + final CameraUpdate cameraUpdate = Convert.toCameraUpdate(call.argument("cameraUpdate"), mapLibreMap, density); + final Integer duration = call.argument("duration"); + + final OnCameraMoveFinishedListener onCameraMoveFinishedListener = + new OnCameraMoveFinishedListener() { + @Override + public void onFinish() { + super.onFinish(); + result.success(true); + } + + @Override + public void onCancel() { + super.onCancel(); + result.success(false); + } + }; + + if (cameraUpdate != null && duration != null && duration > 0) { + // camera transformation not handled yet + mapLibreMap.easeCamera(cameraUpdate, duration, false, onCameraMoveFinishedListener); + } else if (cameraUpdate != null) { + // camera transformation not handled yet + mapLibreMap.easeCamera(cameraUpdate, onCameraMoveFinishedListener); + } else { + result.success(false); + } + break; + } + case "map#queryCameraPosition": + { + result.success(Convert.toJson(mapLibreMap.getCameraPosition())); + break; + } + case "map#editGeoJsonSource": + { + boolean ret = false; + if (mapLibreMap != null) { + Style style = mapLibreMap.getStyle(); + if (style != null) { + try { + GeoJsonSource source = style.getSourceAs(call.argument("id")); + if (source != null) { + source.setGeoJson((String)call.argument("data")); + ret = true; + } + } catch (Exception e) {} + } + } + Map reply = new HashMap<>(); + reply.put("result", ret); + result.success(reply); + break; + } + case "map#editGeoJsonUrl": + { + boolean ret = false; + if (mapLibreMap != null) { + Style style = mapLibreMap.getStyle(); + if (style != null) { + try { + GeoJsonSource source = style.getSourceAs(call.argument("id")); + if (source != null) { + source.setUrl((String)call.argument("url")); + ret = true; + } + } catch (Exception e) {} + } + } + Map reply = new HashMap<>(); + reply.put("result", ret); + result.success(reply); + break; + } + case "map#setLayerFilter": + { + boolean ret = false; + if (mapLibreMap != null) { + Style style = mapLibreMap.getStyle(); + if (style != null) { + try { + Layer layer = style.getLayer(call.argument("id")); + if (layer != null) { + String filter = call.argument("filter"); + if (filter != null) { + Expression expression = Expression.raw(filter); + if (expression != null) { + if (layer instanceof LineLayer) { + ((LineLayer)layer).setFilter(expression); + ret = true; + } else if (layer instanceof FillLayer) { + ((FillLayer)layer).setFilter(expression); + ret = true; + } else if (layer instanceof SymbolLayer) { + ((SymbolLayer)layer).setFilter(expression); + ret = true; + } + } + } + } + } catch (Exception e) { + e.printStackTrace(); + } + } + } + Map reply = new HashMap<>(); + reply.put("result", ret); + result.success(reply); + break; + } + case "map#getStyle": + { + Map reply = new HashMap<>(); + boolean ret = false; + if (mapLibreMap != null) { + Style style = mapLibreMap.getStyle(); + if (style != null) { + try { + String json = style.getJson(); + reply.put("json", json); + ret = true; + } catch (Exception e) {} + } + } + reply.put("result", ret); + result.success(reply); + break; + } + case "map#setCustomHeaders": + { + if (mapLibreMap != null) { + HashMap headers = (HashMap)call.argument("headers"); + List filter = (List)call.argument("filter"); + MapLibreCustomHttpInterceptor.setCustomHeaders(headers, filter, result); + } else { + result.success(null); + } + break; + } + case "map#getCustomHeaders": + { + if (mapLibreMap != null) { + result.success(MapLibreCustomHttpInterceptor.CustomHeaders); + } else { + result.success(null); + } + break; + } + case "map#invalidateAmbientCache": + { + OfflineManager fileSource = OfflineManager.Companion.getInstance(context); + + fileSource.invalidateAmbientCache( + new OfflineManager.FileSourceCallback() { + @Override + public void onSuccess() { + result.success(null); + } + + @Override + public void onError(@NonNull String message) { + result.error("MAPBOX CACHE ERROR", message, null); + } + }); + break; + } + case "map#clearAmbientCache": + { + OfflineManager fileSource = OfflineManager.Companion.getInstance(context); + + fileSource.clearAmbientCache( + new OfflineManager.FileSourceCallback() { + @Override + public void onSuccess() { + result.success(null); + } + + @Override + public void onError(@NonNull String message) { + result.error("MAPBOX CACHE ERROR", message, null); + } + }); + break; + } + case "source#addGeoJson": + { + final String sourceId = call.argument("sourceId"); + final String geojson = call.argument("geojson"); + addGeoJsonSource(sourceId, geojson); + result.success(null); + break; + } + case "source#setGeoJson": + { + final String sourceId = call.argument("sourceId"); + final String geojson = call.argument("geojson"); + setGeoJsonSource(sourceId, geojson); + result.success(null); + break; + } + case "source#setFeature": + { + final String sourceId = call.argument("sourceId"); + final String geojsonFeature = call.argument("geojsonFeature"); + setGeoJsonFeature(sourceId, geojsonFeature); + result.success(null); + break; + } + case "symbolLayer#add": + { + final String sourceId = call.argument("sourceId"); + final String layerId = call.argument("layerId"); + final String belowLayerId = call.argument("belowLayerId"); + final String sourceLayer = call.argument("sourceLayer"); + final Double minzoom = call.argument("minzoom"); + final Double maxzoom = call.argument("maxzoom"); + final String filter = call.argument("filter"); + final boolean enableInteraction = call.argument("enableInteraction"); + final PropertyValue[] properties = + LayerPropertyConverter.interpretSymbolLayerProperties(call.argument("properties")); + + Expression filterExpression = parseFilter(filter); + + addSymbolLayer( + layerId, + sourceId, + belowLayerId, + sourceLayer, + minzoom != null ? minzoom.floatValue() : null, + maxzoom != null ? maxzoom.floatValue() : null, + properties, + enableInteraction, + filterExpression); + updateLocationComponentLayer(); + + result.success(null); + break; + } + case "lineLayer#add": + { + final String sourceId = call.argument("sourceId"); + final String layerId = call.argument("layerId"); + final String belowLayerId = call.argument("belowLayerId"); + final String sourceLayer = call.argument("sourceLayer"); + final Double minzoom = call.argument("minzoom"); + final Double maxzoom = call.argument("maxzoom"); + final String filter = call.argument("filter"); + final boolean enableInteraction = call.argument("enableInteraction"); + final PropertyValue[] properties = + LayerPropertyConverter.interpretLineLayerProperties(call.argument("properties")); + + Expression filterExpression = parseFilter(filter); + + addLineLayer( + layerId, + sourceId, + belowLayerId, + sourceLayer, + minzoom != null ? minzoom.floatValue() : null, + maxzoom != null ? maxzoom.floatValue() : null, + properties, + enableInteraction, + filterExpression); + updateLocationComponentLayer(); + + result.success(null); + break; + } + case "layer#setProperties": { + final String layerId = call.argument("layerId"); + + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + + Layer layer = style.getLayer(layerId); + + if (layer != null) { + final PropertyValue[] properties; + + if (layer instanceof LineLayer) { + properties = LayerPropertyConverter + .interpretLineLayerProperties(call.argument("properties")); + } else if (layer instanceof FillLayer) { + properties = LayerPropertyConverter + .interpretFillLayerProperties(call.argument("properties")); + } else if (layer instanceof CircleLayer) { + properties = LayerPropertyConverter + .interpretCircleLayerProperties(call.argument("properties")); + } else if (layer instanceof SymbolLayer) { + properties = LayerPropertyConverter + .interpretSymbolLayerProperties(call.argument("properties")); + } else if (layer instanceof RasterLayer) { + properties = LayerPropertyConverter + .interpretRasterLayerProperties(call.argument("properties")); + } else if (layer instanceof HillshadeLayer) { + properties = LayerPropertyConverter + .interpretHillshadeLayerProperties(call.argument("properties")); + } else { + result.error("UNSUPPORTED_LAYER_TYPE", "Layer type not supported", null); + return; + } + layer.setProperties(properties); + result.success(null); + } else { + result.error("LAYER_NOT_FOUND_ERROR", "Layer " + layerId + "not found", null); + } + + break; + } + case "fillLayer#add": + { + final String sourceId = call.argument("sourceId"); + final String layerId = call.argument("layerId"); + final String belowLayerId = call.argument("belowLayerId"); + final String sourceLayer = call.argument("sourceLayer"); + final Double minzoom = call.argument("minzoom"); + final Double maxzoom = call.argument("maxzoom"); + final String filter = call.argument("filter"); + final boolean enableInteraction = call.argument("enableInteraction"); + final PropertyValue[] properties = + LayerPropertyConverter.interpretFillLayerProperties(call.argument("properties")); + + Expression filterExpression = parseFilter(filter); + + addFillLayer( + layerId, + sourceId, + belowLayerId, + sourceLayer, + minzoom != null ? minzoom.floatValue() : null, + maxzoom != null ? maxzoom.floatValue() : null, + properties, + enableInteraction, + filterExpression); + updateLocationComponentLayer(); + + result.success(null); + break; + } + case "fillExtrusionLayer#add": + { + final String sourceId = call.argument("sourceId"); + final String layerId = call.argument("layerId"); + final String belowLayerId = call.argument("belowLayerId"); + final String sourceLayer = call.argument("sourceLayer"); + final Double minzoom = call.argument("minzoom"); + final Double maxzoom = call.argument("maxzoom"); + final String filter = call.argument("filter"); + final boolean enableInteraction = call.argument("enableInteraction"); + final PropertyValue[] properties = + LayerPropertyConverter.interpretFillExtrusionLayerProperties( + call.argument("properties")); + + Expression filterExpression = parseFilter(filter); + + addFillExtrusionLayer( + layerId, + sourceId, + belowLayerId, + sourceLayer, + minzoom != null ? minzoom.floatValue() : null, + maxzoom != null ? maxzoom.floatValue() : null, + properties, + enableInteraction, + filterExpression); + updateLocationComponentLayer(); + + result.success(null); + break; + } + case "circleLayer#add": + { + final String sourceId = call.argument("sourceId"); + final String layerId = call.argument("layerId"); + final String belowLayerId = call.argument("belowLayerId"); + final String sourceLayer = call.argument("sourceLayer"); + final Double minzoom = call.argument("minzoom"); + final Double maxzoom = call.argument("maxzoom"); + final String filter = call.argument("filter"); + final boolean enableInteraction = call.argument("enableInteraction"); + final PropertyValue[] properties = + LayerPropertyConverter.interpretCircleLayerProperties(call.argument("properties")); + + Expression filterExpression = parseFilter(filter); + + addCircleLayer( + layerId, + sourceId, + belowLayerId, + sourceLayer, + minzoom != null ? minzoom.floatValue() : null, + maxzoom != null ? maxzoom.floatValue() : null, + properties, + enableInteraction, + filterExpression); + updateLocationComponentLayer(); + + result.success(null); + break; + } + case "rasterLayer#add": + { + final String sourceId = call.argument("sourceId"); + final String layerId = call.argument("layerId"); + final String belowLayerId = call.argument("belowLayerId"); + final Double minzoom = call.argument("minzoom"); + final Double maxzoom = call.argument("maxzoom"); + final PropertyValue[] properties = + LayerPropertyConverter.interpretRasterLayerProperties(call.argument("properties")); + addRasterLayer( + layerId, + sourceId, + minzoom != null ? minzoom.floatValue() : null, + maxzoom != null ? maxzoom.floatValue() : null, + belowLayerId, + properties, + null); + updateLocationComponentLayer(); + + result.success(null); + break; + } + case "hillshadeLayer#add": + { + final String sourceId = call.argument("sourceId"); + final String layerId = call.argument("layerId"); + final String belowLayerId = call.argument("belowLayerId"); + final Double minzoom = call.argument("minzoom"); + final Double maxzoom = call.argument("maxzoom"); + final PropertyValue[] properties = + LayerPropertyConverter.interpretHillshadeLayerProperties(call.argument("properties")); + addHillshadeLayer( + layerId, + sourceId, + minzoom != null ? minzoom.floatValue() : null, + maxzoom != null ? maxzoom.floatValue() : null, + belowLayerId, + properties, + null); + updateLocationComponentLayer(); + + result.success(null); + break; + } + case "heatmapLayer#add": + { + final String sourceId = call.argument("sourceId"); + final String layerId = call.argument("layerId"); + final String belowLayerId = call.argument("belowLayerId"); + final Double minzoom = call.argument("minzoom"); + final Double maxzoom = call.argument("maxzoom"); + final PropertyValue[] properties = + LayerPropertyConverter.interpretHeatmapLayerProperties(call.argument("properties")); + addHeatmapLayer( + layerId, + sourceId, + minzoom != null ? minzoom.floatValue() : null, + maxzoom != null ? maxzoom.floatValue() : null, + belowLayerId, + properties, + null); + updateLocationComponentLayer(); + + result.success(null); + break; + } + case "locationComponent#getLastLocation": + { + Log.e(TAG, "location component: getLastLocation"); + if (this.myLocationEnabled + && locationComponent != null + && locationComponent.isLocationComponentActivated() + && locationComponent.getLocationEngine() != null) { + Map reply = new HashMap<>(); + + mapLibreMap.getLocationComponent().getLocationEngine().getLastLocation( + new LocationEngineCallback() { + @Override + public void onSuccess(LocationEngineResult locationEngineResult) { + Location lastLocation = locationEngineResult.getLastLocation(); + if (lastLocation != null) { + reply.put("latitude", lastLocation.getLatitude()); + reply.put("longitude", lastLocation.getLongitude()); + reply.put("altitude", lastLocation.getAltitude()); + result.success(reply); + } else { + result.error("", "", null); // ??? + } + } + + @Override + public void onFailure(@NonNull Exception exception) { + result.error("", "", null); // ??? + } + }); + } else { + result.error( + "LOCATION DISABLED", + "Location is disabled or location component is unavailable", + null); + } + break; + } + case "style#addImage": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + // Configure bitmap options to prevent density-based scaling + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inScaled = false; // Disable automatic scaling + options.inDensity = 0; // No source density + options.inTargetDensity = 0; // No target density + + Bitmap bitmap = BitmapFactory.decodeByteArray( + call.argument("bytes"), + 0, + call.argument("length"), + options); + + style.addImage( + call.argument("name"), + bitmap, + call.argument("sdf")); + result.success(null); + break; + } + case "style#addImageSource": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + List coordinates = Convert.toLatLngList(call.argument("coordinates"), false); + style.addSource( + new ImageSource( + call.argument("imageSourceId"), + new LatLngQuad( + coordinates.get(0), + coordinates.get(1), + coordinates.get(2), + coordinates.get(3)), + BitmapFactory.decodeByteArray( + call.argument("bytes"), 0, call.argument("length")))); + result.success(null); + break; + } + case "style#updateImageSource": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + ImageSource imageSource = style.getSourceAs(call.argument("imageSourceId")); + List coordinates = Convert.toLatLngList(call.argument("coordinates"), false); + if (coordinates != null) { + imageSource.setCoordinates( + new LatLngQuad( + coordinates.get(0), + coordinates.get(1), + coordinates.get(2), + coordinates.get(3))); + } + byte[] bytes = call.argument("bytes"); + if (bytes != null) { + imageSource.setImage(BitmapFactory.decodeByteArray(bytes, 0, call.argument("length"))); + } + result.success(null); + break; + } + case "style#addSource": + { + final String id = Convert.toString(call.argument("sourceId")); + final Map properties = (Map) call.argument("properties"); + SourcePropertyConverter.addSource(id, properties, style); + result.success(null); + break; + } + + case "style#removeSource": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + style.removeSource((String) call.argument("sourceId")); + result.success(null); + break; + } + case "style#addLayer": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + addRasterLayer( + call.argument("imageLayerId"), + call.argument("imageSourceId"), + call.argument("minzoom") != null + ? ((Double) call.argument("minzoom")).floatValue() + : null, + call.argument("maxzoom") != null + ? ((Double) call.argument("maxzoom")).floatValue() + : null, + null, + new PropertyValue[] {}, + null); + result.success(null); + break; + } + case "style#addLayerBelow": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + addRasterLayer( + call.argument("imageLayerId"), + call.argument("imageSourceId"), + call.argument("minzoom") != null + ? ((Double) call.argument("minzoom")).floatValue() + : null, + call.argument("maxzoom") != null + ? ((Double) call.argument("maxzoom")).floatValue() + : null, + call.argument("belowLayerId"), + new PropertyValue[] {}, + null); + result.success(null); + break; + } + case "style#removeLayer": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + String layerId = call.argument("layerId"); + style.removeLayer(layerId); + interactiveFeatureLayerIds.remove(layerId); + + result.success(null); + break; + } + case "map#setCameraBounds": + { + double west = call.argument("west"); + double north = call.argument("north"); + double south = call.argument("south"); + double east = call.argument("east"); + + int padding = call.argument("padding"); + + LatLng locationOne = new LatLng(north, east); + LatLng locationTwo = new LatLng(south, west); + LatLngBounds latLngBounds = new LatLngBounds.Builder() + .include(locationOne) // Northeast + .include(locationTwo) // Southwest + .build(); + mapLibreMap.easeCamera(CameraUpdateFactory.newLatLngBounds(latLngBounds, + padding), 200); + + break; + } + case "style#setFilter": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + String layerId = call.argument("layerId"); + String filter = call.argument("filter"); + + Layer layer = style.getLayer(layerId); + + JsonParser parser = new JsonParser(); + JsonElement jsonElement = parser.parse(filter); + Expression expression = Expression.Converter.convert(jsonElement); + + if (layer instanceof CircleLayer) { + ((CircleLayer) layer).setFilter(expression); + } else if (layer instanceof FillExtrusionLayer) { + ((FillExtrusionLayer) layer).setFilter(expression); + } else if (layer instanceof FillLayer) { + ((FillLayer) layer).setFilter(expression); + } else if (layer instanceof HeatmapLayer) { + ((HeatmapLayer) layer).setFilter(expression); + } else if (layer instanceof LineLayer) { + ((LineLayer) layer).setFilter(expression); + } else if (layer instanceof SymbolLayer) { + ((SymbolLayer) layer).setFilter(expression); + } else { + result.error( + "INVALID LAYER TYPE", + String.format("Layer '%s' does not support filtering.", layerId), + null); + break; + } + + result.success(null); + break; + } + case "style#getFilter": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + Map reply = new HashMap<>(); + String layerId = call.argument("layerId"); + Layer layer = style.getLayer(layerId); + + Expression filter; + if (layer instanceof CircleLayer) { + filter = ((CircleLayer) layer).getFilter(); + } else if (layer instanceof FillExtrusionLayer) { + filter = ((FillExtrusionLayer) layer).getFilter(); + } else if (layer instanceof FillLayer) { + filter = ((FillLayer) layer).getFilter(); + } else if (layer instanceof HeatmapLayer) { + filter = ((HeatmapLayer) layer).getFilter(); + } else if (layer instanceof LineLayer) { + filter = ((LineLayer) layer).getFilter(); + } else if (layer instanceof SymbolLayer) { + filter = ((SymbolLayer) layer).getFilter(); + } else { + result.error( + "INVALID LAYER TYPE", + String.format("Layer '%s' does not support filtering.", layerId), + null); + break; + } + + reply.put("filter", filter.toString()); + result.success(reply); + break; + } + case "layer#setVisibility": + { + + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + String layerId = call.argument("layerId"); + boolean visible = call.argument("visible"); + + Layer layer = style.getLayer(layerId); + + if (layer != null) { + layer.setProperties(PropertyFactory.visibility(visible ? Property.VISIBLE : Property.NONE)); + } + + result.success(null); + break; + + } + case "map#querySourceFeatures": + { + Map reply = new HashMap<>(); + List features; + + String sourceId = (String) call.argument("sourceId"); + + String sourceLayerId = (String) call.argument("sourceLayerId"); + + List filter = call.argument("filter"); + JsonElement jsonElement = filter == null ? null : new Gson().toJsonTree(filter); + JsonArray jsonArray = null; + if (jsonElement != null && jsonElement.isJsonArray()) { + jsonArray = jsonElement.getAsJsonArray(); + } + Expression filterExpression = + jsonArray == null ? null : Expression.Converter.convert(jsonArray); + + + Source source = style.getSource(sourceId); + if (source instanceof GeoJsonSource) { + features = ((GeoJsonSource) source).querySourceFeatures(filterExpression); + } else if (source instanceof CustomGeometrySource) { + features = ((CustomGeometrySource) source).querySourceFeatures(filterExpression); + } else if (source instanceof VectorSource && sourceLayerId != null) { + features = ((VectorSource) source).querySourceFeatures(new String[] {sourceLayerId}, filterExpression); + } else { + features = Collections.emptyList(); + } + + List featuresJson = new ArrayList<>(); + for (Feature feature : features) { + featuresJson.add(feature.toJson()); + } + reply.put("features", featuresJson); + result.success(reply); + break; + } + case "style#getLayerIds": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + Map reply = new HashMap<>(); + + List layerIds = new ArrayList<>(); + for (Layer layer : style.getLayers()) { + layerIds.add(layer.getId()); + } + + reply.put("layers", layerIds); + result.success(reply); + break; + } + case "style#getSourceIds": + { + if (style == null) { + result.error( + "STYLE IS NULL", + "The style is null. Has onStyleLoaded() already been invoked?", + null); + } + Map reply = new HashMap<>(); + + List sourceIds = new ArrayList<>(); + for (Source source : style.getSources()) { + sourceIds.add(source.getId()); + } + + reply.put("sources", sourceIds); + result.success(reply); + break; + } + case "style#setStyle": + { + // Getting style json, url, path etc. from the flutter side + String styleString = call.argument("style"); + + // Checking if style is null or not + if (styleString != null) { + // If style is not null setting style + setStyleString(styleString); + result.success(null); + } else { + + // else throwing error + result.error( + "STYLE STRING IS NULL", + "The style string is null.", + null + ); + } + break; + } + default: + result.notImplemented(); + } + } + + @Override + public void onCameraMoveStarted(int reason) { + final Map arguments = new HashMap<>(2); + boolean isGesture = reason == MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE; + arguments.put("isGesture", isGesture); + methodChannel.invokeMethod("camera#onMoveStarted", arguments); + } + + @Override + public void onCameraMove() { + if (!trackCameraPosition) { + return; + } + final Map arguments = new HashMap<>(2); + arguments.put("position", Convert.toJson(mapLibreMap.getCameraPosition())); + methodChannel.invokeMethod("camera#onMove", arguments); + } + + @Override + public void onCameraIdle() { + final Map arguments = new HashMap<>(2); + if (trackCameraPosition) { + arguments.put("position", Convert.toJson(mapLibreMap.getCameraPosition())); + } + methodChannel.invokeMethod("camera#onIdle", arguments); + } + + @Override + public void onCameraTrackingChanged(int currentMode) { + final Map arguments = new HashMap<>(2); + switch (currentMode) { + case CameraMode.NONE: + arguments.put("mode", 0); + break; + case CameraMode.TRACKING: + arguments.put("mode", 1); + break; + case CameraMode.TRACKING_COMPASS: + arguments.put("mode", 2); + break; + case CameraMode.TRACKING_GPS: + arguments.put("mode", 3); + break; + default: + Log.e(TAG, "Unable to map " + currentMode + " to a tracking mode"); + return; + } + + methodChannel.invokeMethod("map#onCameraTrackingChanged", arguments); + } + + @Override + public void onCameraTrackingDismissed() { + this.myLocationTrackingMode = 0; + methodChannel.invokeMethod("map#onCameraTrackingDismissed", new HashMap<>()); + } + + @Override + public void onDidBecomeIdle() { + methodChannel.invokeMethod("map#onIdle", new HashMap<>()); + } + + @Override + public boolean onMapClick(@NonNull LatLng point) { + PointF pointf = mapLibreMap.getProjection().toScreenLocation(point); + RectF rectF = new RectF(pointf.x - 10, pointf.y - 10, pointf.x + 10, pointf.y + 10); + Pair featureLayerPair = firstFeatureOnLayers(rectF); + final Map arguments = new HashMap<>(); + arguments.put("x", pointf.x); + arguments.put("y", pointf.y); + arguments.put("lng", point.getLongitude()); + arguments.put("lat", point.getLatitude()); + if (featureLayerPair != null && featureLayerPair.first != null) { + arguments.put("layerId", featureLayerPair.second); + arguments.put("id", featureLayerPair.first.id()); + methodChannel.invokeMethod("feature#onTap", arguments); + } else { + methodChannel.invokeMethod("map#onMapClick", arguments); + } + return true; + } + + @Override + public boolean onMapLongClick(@NonNull LatLng point) { + PointF pointf = mapLibreMap.getProjection().toScreenLocation(point); + final Map arguments = new HashMap<>(5); + arguments.put("x", pointf.x); + arguments.put("y", pointf.y); + arguments.put("lng", point.getLongitude()); + arguments.put("lat", point.getLatitude()); + methodChannel.invokeMethod("map#onMapLongClick", arguments); + return true; + } + + @Override + public void dispose() { + if (disposed) { + return; + } + disposed = true; + methodChannel.setMethodCallHandler(null); + destroyMapViewIfNecessary(); + Lifecycle lifecycle = lifecycleProvider.getLifecycle(); + if (lifecycle != null) { + lifecycle.removeObserver(this); + } + } + + private void moveCamera(CameraUpdate cameraUpdate, MethodChannel.Result result) { + if (cameraUpdate != null) { + // camera transformation not handled yet + mapLibreMap.moveCamera( + cameraUpdate, + new OnCameraMoveFinishedListener() { + @Override + public void onFinish() { + super.onFinish(); + result.success(true); + } + + @Override + public void onCancel() { + super.onCancel(); + result.success(false); + } + }); + + // moveCamera(cameraUpdate); + } else { + result.success(false); + } + } + + private void animateCamera( + CameraUpdate cameraUpdate, Integer duration, MethodChannel.Result result) { + final OnCameraMoveFinishedListener onCameraMoveFinishedListener = + new OnCameraMoveFinishedListener() { + @Override + public void onFinish() { + super.onFinish(); + result.success(true); + } + + @Override + public void onCancel() { + super.onCancel(); + result.success(false); + } + }; + if (cameraUpdate != null && duration != null) { + // camera transformation not handled yet + mapLibreMap.animateCamera(cameraUpdate, duration, onCameraMoveFinishedListener); + } else if (cameraUpdate != null) { + // camera transformation not handled yet + mapLibreMap.animateCamera(cameraUpdate, onCameraMoveFinishedListener); + } else { + result.success(false); + } + } + + /** + * Destroy the MapView and cleans up listeners. + * It's very important to call mapViewContainer.removeView(mapView) to make sure + * that {@link TextureView#onDetachedFromWindowInternal()} is called which releases the + * underlying surface. + * This is required due to an FlutterEngine change that was introduce when updating from + * Flutter 2.10.5 to Flutter 3.10.0. + * This FlutterEngine change is not calling `removeView` on a PlatformView which causes the issue. + *

+ * For more information check out: + * Flutter issue + * Flutter Engine commit that introduced the issue + * The reported issue in the MapLibre repo + */ + private void destroyMapViewIfNecessary() { + if (mapView == null) { + return; + } + + if (locationComponent != null) { + locationComponent.setLocationComponentEnabled(false); + } + stopListeningForLocationUpdates(); + + mapViewContainer.removeView(mapView); + + mapView.onStop(); + mapView.onDestroy(); + + mapView = null; + } + + @Override + public void onCreate(@NonNull LifecycleOwner owner) { + if (disposed) { + return; + } + mapView.onCreate(null); + } + + @Override + public void onStart(@NonNull LifecycleOwner owner) { + if (disposed) { + return; + } + mapView.onStart(); + } + + @Override + public void onResume(@NonNull LifecycleOwner owner) { + if (disposed) { + return; + } + mapView.onResume(); + if (myLocationEnabled) { + startListeningForLocationUpdates(); + } + } + + @Override + public void onPause(@NonNull LifecycleOwner owner) { + if (disposed) { + return; + } + mapView.onPause(); + } + + @Override + public void onStop(@NonNull LifecycleOwner owner) { + if (disposed) { + return; + } + mapView.onStop(); + } + + @Override + public void onDestroy(@NonNull LifecycleOwner owner) { + owner.getLifecycle().removeObserver(this); + if (disposed) { + return; + } + destroyMapViewIfNecessary(); + } + + // MapLibreMapOptionsSink methods + + @Override + public void setCameraTargetBounds(LatLngBounds bounds) { + this.bounds = bounds; + if (mapLibreMap != null) { + mapLibreMap.setLatLngBoundsForCameraTarget(bounds); + } + } + + @Override + public void setLocationEngineProperties(@NotNull LocationEngineRequest locationEngineRequest) { + myLocationEngineFactory.initLocationComponent(context, locationComponent, locationEngineRequest); + } + + @Override + public void setCompassEnabled(boolean compassEnabled) { + mapLibreMap.getUiSettings().setCompassEnabled(compassEnabled); + } + + @Override + public void setTrackCameraPosition(boolean trackCameraPosition) { + this.trackCameraPosition = trackCameraPosition; + } + + @Override + public void setRotateGesturesEnabled(boolean rotateGesturesEnabled) { + mapLibreMap.getUiSettings().setRotateGesturesEnabled(rotateGesturesEnabled); + } + + @Override + public void setScrollGesturesEnabled(boolean scrollGesturesEnabled) { + mapLibreMap.getUiSettings().setScrollGesturesEnabled(scrollGesturesEnabled); + } + + @Override + public void setTiltGesturesEnabled(boolean tiltGesturesEnabled) { + mapLibreMap.getUiSettings().setTiltGesturesEnabled(tiltGesturesEnabled); + } + + @Override + public void setMinMaxZoomPreference(Float min, Float max) { + mapLibreMap.setMinZoomPreference(min != null ? min : MapLibreConstants.MINIMUM_ZOOM); + mapLibreMap.setMaxZoomPreference(max != null ? max : MapLibreConstants.MAXIMUM_ZOOM); + } + + @Override + public void setZoomGesturesEnabled(boolean zoomGesturesEnabled) { + mapLibreMap.getUiSettings().setZoomGesturesEnabled(zoomGesturesEnabled); + } + + @Override + public void setMyLocationEnabled(boolean myLocationEnabled) { + if (this.myLocationEnabled == myLocationEnabled) { + return; + } + this.myLocationEnabled = myLocationEnabled; + if (mapLibreMap != null) { + updateMyLocationEnabled(); + } + } + + @Override + public void setMyLocationTrackingMode(int myLocationTrackingMode) { + if (mapLibreMap != null) { + // ensure that location is trackable + updateMyLocationEnabled(); + } + if (this.myLocationTrackingMode == myLocationTrackingMode) { + return; + } + this.myLocationTrackingMode = myLocationTrackingMode; + if (mapLibreMap != null && locationComponent != null) { + updateMyLocationTrackingMode(); + } + } + + @Override + public void setMyLocationRenderMode(int myLocationRenderMode) { + if (this.myLocationRenderMode == myLocationRenderMode) { + return; + } + this.myLocationRenderMode = myLocationRenderMode; + if (mapLibreMap != null && locationComponent != null) { + updateMyLocationRenderMode(); + } + } + + @Override + public void setLogoEnabled(boolean logoEnabled) { + mapLibreMap.getUiSettings().setLogoEnabled(logoEnabled); + } + + @Override + public void setLogoViewGravity(int gravity) { + switch (gravity) { + case 0: + mapLibreMap.getUiSettings().setLogoGravity(Gravity.TOP | Gravity.START); + break; + case 1: + mapLibreMap.getUiSettings().setLogoGravity(Gravity.TOP | Gravity.END); + break; + default: + case 2: + mapLibreMap.getUiSettings().setLogoGravity(Gravity.BOTTOM | Gravity.START); + break; + case 3: + mapLibreMap.getUiSettings().setLogoGravity(Gravity.BOTTOM | Gravity.END); + break; + } + } + + public void setLogoViewMargins(int x, int y) { + mapLibreMap.getUiSettings().setLogoMargins(x, 0, 0, y); + } + + @Override + public void setCompassGravity(int gravity) { + switch (gravity) { + case 0: + mapLibreMap.getUiSettings().setCompassGravity(Gravity.TOP | Gravity.START); + break; + default: + case 1: + mapLibreMap.getUiSettings().setCompassGravity(Gravity.TOP | Gravity.END); + break; + case 2: + mapLibreMap.getUiSettings().setCompassGravity(Gravity.BOTTOM | Gravity.START); + break; + case 3: + mapLibreMap.getUiSettings().setCompassGravity(Gravity.BOTTOM | Gravity.END); + break; + } + } + + @Override + public void setCompassViewMargins(int x, int y) { + switch (mapLibreMap.getUiSettings().getCompassGravity()) { + case Gravity.TOP | Gravity.START: + mapLibreMap.getUiSettings().setCompassMargins(x, y, 0, 0); + break; + default: + case Gravity.TOP | Gravity.END: + mapLibreMap.getUiSettings().setCompassMargins(0, y, x, 0); + break; + case Gravity.BOTTOM | Gravity.START: + mapLibreMap.getUiSettings().setCompassMargins(x, 0, 0, y); + break; + case Gravity.BOTTOM | Gravity.END: + mapLibreMap.getUiSettings().setCompassMargins(0, 0, x, y); + break; + } + } + + @Override + public void setAttributionButtonGravity(int gravity) { + switch (gravity) { + case 0: + mapLibreMap.getUiSettings().setAttributionGravity(Gravity.TOP | Gravity.START); + break; + default: + case 1: + mapLibreMap.getUiSettings().setAttributionGravity(Gravity.TOP | Gravity.END); + break; + case 2: + mapLibreMap.getUiSettings().setAttributionGravity(Gravity.BOTTOM | Gravity.START); + break; + case 3: + mapLibreMap.getUiSettings().setAttributionGravity(Gravity.BOTTOM | Gravity.END); + break; + } + } + + @Override + public void setAttributionButtonMargins(int x, int y) { + switch (mapLibreMap.getUiSettings().getAttributionGravity()) { + case Gravity.TOP | Gravity.START: + mapLibreMap.getUiSettings().setAttributionMargins(x, y, 0, 0); + break; + default: + case Gravity.TOP | Gravity.END: + mapLibreMap.getUiSettings().setAttributionMargins(0, y, x, 0); + break; + case Gravity.BOTTOM | Gravity.START: + mapLibreMap.getUiSettings().setAttributionMargins(x, 0, 0, y); + break; + case Gravity.BOTTOM | Gravity.END: + mapLibreMap.getUiSettings().setAttributionMargins(0, 0, x, y); + break; + } + } + + @Override + public void setForegroundLoadColor(int color) { + // foregroundLoadColor is only useful during initial map creation + // not for runtime updates, so this is a no-op + } + + @Override + public void setTranslucentTextureSurface(boolean translucentTextureSurface) { + // translucentTextureSurface is only useful during initial map creation + // not for runtime updates, so this is a no-op + } + + private void updateMyLocationEnabled() { + if (this.locationComponent == null && mapLibreMap.getStyle() != null && myLocationEnabled) { + enableLocationComponent(mapLibreMap.getStyle()); + } + + if (myLocationEnabled) { + startListeningForLocationUpdates(); + } else { + stopListeningForLocationUpdates(); + } + + if (locationComponent != null) { + locationComponent.setLocationComponentEnabled(myLocationEnabled); + } + } + + private void startListeningForLocationUpdates() { + if (locationEngineCallback == null + && locationComponent != null + && locationComponent.isLocationComponentActivated() + && locationComponent.getLocationEngine() != null) { + locationEngineCallback = + new LocationEngineCallback() { + @Override + public void onSuccess(LocationEngineResult result) { + onUserLocationUpdate(result.getLastLocation()); + } + + @Override + public void onFailure(@NonNull Exception exception) {} + }; + locationComponent + .getLocationEngine() + .requestLocationUpdates( + locationComponent.getLocationEngineRequest(), locationEngineCallback, null); + } + } + + private void stopListeningForLocationUpdates() { + if (locationEngineCallback != null + && locationComponent != null + && locationComponent.isLocationComponentActivated() + && locationComponent.getLocationEngine() != null) { + locationComponent.getLocationEngine().removeLocationUpdates(locationEngineCallback); + locationEngineCallback = null; + } + } + + private void updateMyLocationTrackingMode() { + int[] mapboxTrackingModes = + new int[] { + CameraMode.NONE, CameraMode.TRACKING, CameraMode.TRACKING_COMPASS, CameraMode.TRACKING_GPS + }; + locationComponent.setCameraMode(mapboxTrackingModes[this.myLocationTrackingMode]); + } + + private void updateMyLocationRenderMode() { + int[] mapboxRenderModes = new int[] {RenderMode.NORMAL, RenderMode.COMPASS, RenderMode.GPS}; + locationComponent.setRenderMode(mapboxRenderModes[this.myLocationRenderMode]); + } + + private boolean hasLocationPermission() { + return checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) + == PackageManager.PERMISSION_GRANTED + || checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) + == PackageManager.PERMISSION_GRANTED; + } + + private int checkSelfPermission(String permission) { + if (permission == null) { + throw new IllegalArgumentException("permission is null"); + } + return context.checkPermission( + permission, android.os.Process.myPid(), android.os.Process.myUid()); + } + + /** + * Tries to find highest scale image for display type + * + * @param imageId + * @param density + * @return + */ + private Bitmap getScaledImage(String imageId, float density) { + AssetFileDescriptor assetFileDescriptor; + + // Split image path into parts. + List imagePathList = Arrays.asList(imageId.split("/")); + List assetPathList = new ArrayList<>(); + + // "On devices with a device pixel ratio of 1.8, the asset .../2.0x/my_icon.png would be chosen. + // For a device pixel ratio of 2.7, the asset .../3.0x/my_icon.png would be chosen." + // Source: https://flutter.dev/docs/development/ui/assets-and-images#resolution-aware + for (int i = (int) Math.ceil(density); i > 0; i--) { + String assetPath; + if (i == 1) { + // If density is 1.0x then simply take the default asset path + assetPath = MapLibreMapsPlugin.flutterAssets.getAssetFilePathByName(imageId); + } else { + // Build a resolution aware asset path as follows: + // // + // where ratio is 1.0x, 2.0x or 3.0x. + StringBuilder stringBuilder = new StringBuilder(); + for (int j = 0; j < imagePathList.size() - 1; j++) { + stringBuilder.append(imagePathList.get(j)); + stringBuilder.append("/"); + } + stringBuilder.append(((float) i) + "x"); + stringBuilder.append("/"); + stringBuilder.append(imagePathList.get(imagePathList.size() - 1)); + assetPath = MapLibreMapsPlugin.flutterAssets.getAssetFilePathByName(stringBuilder.toString()); + } + // Build up a list of resolution aware asset paths. + assetPathList.add(assetPath); + } + + // Iterate over asset paths and get the highest scaled asset (as a bitmap). + Bitmap bitmap = null; + for (String assetPath : assetPathList) { + try { + // Read path (throws exception if doesn't exist). + assetFileDescriptor = mapView.getContext().getAssets().openFd(assetPath); + InputStream assetStream = assetFileDescriptor.createInputStream(); + bitmap = BitmapFactory.decodeStream(assetStream); + assetFileDescriptor.close(); // Close for memory + break; // If exists, break + } catch (IOException e) { + // Skip + } + } + return bitmap; + } + + boolean onMoveBegin(MoveGestureDetector detector) { + // onMoveBegin gets called even during a move - move end is also not called unless this function + // returns + // true at least once. To avoid redundant queries only check for feature if the previous event + // was ACTION_DOWN + if (detector.getPreviousEvent().getActionMasked() == MotionEvent.ACTION_DOWN + && detector.getPointersCount() == 1) { + PointF pointf = detector.getFocalPoint(); + LatLng origin = mapLibreMap.getProjection().fromScreenLocation(pointf); + RectF rectF = new RectF(pointf.x - 10, pointf.y - 10, pointf.x + 10, pointf.y + 10); + Pair featureLayerPair = firstFeatureOnLayers(rectF); + if (featureLayerPair != null && featureLayerPair.first != null && startDragging(featureLayerPair.first, origin)) { + invokeFeatureDrag(pointf, "start"); + return true; + } + } + return false; + } + + private void invokeFeatureDrag(PointF pointf, String eventType) { + LatLng current = mapLibreMap.getProjection().fromScreenLocation(pointf); + + final Map arguments = new HashMap<>(9); + arguments.put("id", draggedFeature.id()); + arguments.put("x", pointf.x); + arguments.put("y", pointf.y); + arguments.put("originLng", dragOrigin.getLongitude()); + arguments.put("originLat", dragOrigin.getLatitude()); + arguments.put("currentLng", current.getLongitude()); + arguments.put("currentLat", current.getLatitude()); + arguments.put("eventType", eventType); + arguments.put("deltaLng", current.getLongitude() - dragPrevious.getLongitude()); + arguments.put("deltaLat", current.getLatitude() - dragPrevious.getLatitude()); + dragPrevious = current; + methodChannel.invokeMethod("feature#onDrag", arguments); + } + + boolean onMove(MoveGestureDetector detector) { + if (draggedFeature != null) { + if (detector.getPointersCount() > 1) { + stopDragging(); + return true; + } + PointF pointf = detector.getFocalPoint(); + invokeFeatureDrag(pointf, "drag"); + return false; + } + return true; + } + + void onMoveEnd(MoveGestureDetector detector) { + PointF pointf = detector.getFocalPoint(); + invokeFeatureDrag(pointf, "end"); + stopDragging(); + } + + boolean startDragging(@NonNull Feature feature, @NonNull LatLng origin) { + final boolean draggable = + feature.hasNonNullValueForProperty("draggable") + ? feature.getBooleanProperty("draggable") + : false; + if (draggable) { + draggedFeature = feature; + dragPrevious = origin; + dragOrigin = origin; + return true; + } + return false; + } + + void stopDragging() { + draggedFeature = null; + dragOrigin = null; + dragPrevious = null; + } + + /** Simple Listener to listen for the status of camera movements. */ + public class OnCameraMoveFinishedListener implements MapLibreMap.CancelableCallback { + @Override + public void onFinish() {} + + @Override + public void onCancel() {} + } + + private class MoveGestureListener implements MoveGestureDetector.OnMoveGestureListener { + + @Override + public boolean onMoveBegin(MoveGestureDetector detector) { + return MapLibreMapController.this.onMoveBegin(detector); + } + + @Override + public boolean onMove(MoveGestureDetector detector, float distanceX, float distanceY) { + return MapLibreMapController.this.onMove(detector); + } + + @Override + public void onMoveEnd(MoveGestureDetector detector, float velocityX, float velocityY) { + MapLibreMapController.this.onMoveEnd(detector); + } + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapFactory.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapFactory.java new file mode 100644 index 00000000..7fdda1d6 --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapFactory.java @@ -0,0 +1,48 @@ +package org.maplibre.maplibregl; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.maplibre.android.camera.CameraPosition; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.StandardMessageCodec; +import io.flutter.plugin.platform.PlatformView; +import io.flutter.plugin.platform.PlatformViewFactory; +import java.util.Map; + +public class MapLibreMapFactory extends PlatformViewFactory { + + private final BinaryMessenger messenger; + private final MapLibreMapsPlugin.LifecycleProvider lifecycleProvider; + + public MapLibreMapFactory( + BinaryMessenger messenger, MapLibreMapsPlugin.LifecycleProvider lifecycleProvider) { + super(StandardMessageCodec.INSTANCE); + this.messenger = messenger; + this.lifecycleProvider = lifecycleProvider; + } + + @NonNull + @Override + public PlatformView create(Context context, int id, Object args) { + Map params = (Map) args; + final MapLibreMapBuilder builder = new MapLibreMapBuilder(); + + Convert.interpretMapLibreMapOptions(params.get("options"), builder, context); + if (params.containsKey("initialCameraPosition")) { + CameraPosition position = Convert.toCameraPosition(params.get("initialCameraPosition")); + builder.setInitialCameraPosition(position); + } + if (params.containsKey("dragEnabled")) { + boolean dragEnabled = Convert.toBoolean(params.get("dragEnabled")); + builder.setDragEnabled(dragEnabled); + } + if(params.containsKey("styleString")) { + String styleString = Convert.toString(params.get("styleString")); + builder.setStyleString(styleString); + } + + return builder.build(id, context, messenger, lifecycleProvider); + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapOptionsSink.kt b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapOptionsSink.kt new file mode 100644 index 00000000..bc1575c8 --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapOptionsSink.kt @@ -0,0 +1,56 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +package org.maplibre.maplibregl + +import org.maplibre.android.geometry.LatLngBounds +import org.maplibre.android.location.engine.LocationEngineRequest + +/** Receiver of MapLibreMap configuration options. */ +internal interface MapLibreMapOptionsSink { + // todo: dddd replace with CameraPosition.Builder target + fun setCameraTargetBounds(bounds: LatLngBounds) + + fun setCompassEnabled(compassEnabled: Boolean) + + // TODO: styleString is not actually a part of options. consider moving + fun setStyleString(styleString: String) + + fun setMinMaxZoomPreference(min: Float?, max: Float?) + + fun setRotateGesturesEnabled(rotateGesturesEnabled: Boolean) + + fun setScrollGesturesEnabled(scrollGesturesEnabled: Boolean) + + fun setTiltGesturesEnabled(tiltGesturesEnabled: Boolean) + + fun setTrackCameraPosition(trackCameraPosition: Boolean) + + fun setZoomGesturesEnabled(zoomGesturesEnabled: Boolean) + + fun setMyLocationEnabled(myLocationEnabled: Boolean) + + fun setMyLocationTrackingMode(myLocationTrackingMode: Int) + + fun setMyLocationRenderMode(myLocationRenderMode: Int) + + fun setLogoEnabled(logoEnabled: Boolean) + + fun setLogoViewGravity(gravity: Int) + + fun setLogoViewMargins(x: Int, y: Int) + + fun setCompassGravity(gravity: Int) + + fun setCompassViewMargins(x: Int, y: Int) + + fun setAttributionButtonGravity(gravity: Int) + + fun setAttributionButtonMargins(x: Int, y: Int) + + fun setLocationEngineProperties(locationEngineRequest: LocationEngineRequest) + + fun setForegroundLoadColor(loadColor: Int) + + fun setTranslucentTextureSurface(translucentTextureSurface: Boolean) +} \ No newline at end of file diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapsPlugin.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapsPlugin.java new file mode 100644 index 00000000..edf891ca --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreMapsPlugin.java @@ -0,0 +1,104 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package org.maplibre.maplibregl; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.Lifecycle; +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.embedding.engine.plugins.activity.ActivityAware; +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; +import io.flutter.embedding.engine.plugins.lifecycle.HiddenLifecycleReference; +import io.flutter.plugin.common.MethodChannel; + +/** + * Plugin for controlling a set of MapLibreMap views to be shown as overlays on top of the Flutter + * view. The overlay should be hidden during transformations or while Flutter is rendering on top of + * the map. A Texture drawn using MapLibreMap bitmap snapshots can then be shown instead of the + * overlay. + */ +public class MapLibreMapsPlugin implements FlutterPlugin, ActivityAware { + + static FlutterAssets flutterAssets; + private Lifecycle lifecycle; + + public MapLibreMapsPlugin() { + // no-op + } + + // New Plugin APIs + + @Override + public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) { + flutterAssets = binding.getFlutterAssets(); + + MethodChannel methodChannel = + new MethodChannel(binding.getBinaryMessenger(), "plugins.flutter.io/maplibre_gl"); + methodChannel.setMethodCallHandler(new GlobalMethodHandler(binding)); + + binding + .getPlatformViewRegistry() + .registerViewFactory( + "plugins.flutter.io/maplibre_gl", + new MapLibreMapFactory( + binding.getBinaryMessenger(), + new LifecycleProvider() { + @Nullable + @Override + public Lifecycle getLifecycle() { + return lifecycle; + } + })); + } + + @Override + public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { + // no-op + } + + @Override + public void onAttachedToActivity(@NonNull ActivityPluginBinding binding) { + lifecycle = FlutterLifecycleAdapter.getActivityLifecycle(binding); + } + + @Override + public void onDetachedFromActivityForConfigChanges() { + onDetachedFromActivity(); + } + + @Override + public void onReattachedToActivityForConfigChanges(@NonNull ActivityPluginBinding binding) { + onAttachedToActivity(binding); + } + + @Override + public void onDetachedFromActivity() { + lifecycle = null; + } + + + interface LifecycleProvider { + @Nullable + Lifecycle getLifecycle(); + } + + /** Provides a static method for extracting lifecycle objects from Flutter plugin bindings. */ + public static class FlutterLifecycleAdapter { + + /** + * Returns the lifecycle object for the activity a plugin is bound to. + * + *

Returns null if the Flutter engine version does not include the lifecycle extraction code. + * (this probably means the Flutter engine version is too old). + */ + @NonNull + public static Lifecycle getActivityLifecycle( + @NonNull ActivityPluginBinding activityPluginBinding) { + HiddenLifecycleReference reference = + (HiddenLifecycleReference) activityPluginBinding.getLifecycle(); + return reference.getLifecycle(); + } + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreUtils.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreUtils.java new file mode 100644 index 00000000..dadbd16c --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/MapLibreUtils.java @@ -0,0 +1,12 @@ +package org.maplibre.maplibregl; + +import android.content.Context; +import org.maplibre.android.MapLibre; + +abstract class MapLibreUtils { + private static final String TAG = "MapLibreMapController"; + + static MapLibre getMapLibre(Context context) { + return MapLibre.getInstance(context); + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/OfflineChannelHandlerImpl.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/OfflineChannelHandlerImpl.java new file mode 100644 index 00000000..c29fe17e --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/OfflineChannelHandlerImpl.java @@ -0,0 +1,55 @@ +package org.maplibre.maplibregl; + +import androidx.annotation.Nullable; +import com.google.gson.Gson; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.EventChannel; +import java.util.HashMap; +import java.util.Map; + +public class OfflineChannelHandlerImpl implements EventChannel.StreamHandler { + private EventChannel.EventSink sink; + private Gson gson = new Gson(); + + OfflineChannelHandlerImpl(BinaryMessenger messenger, String channelName) { + EventChannel eventChannel = new EventChannel(messenger, channelName); + eventChannel.setStreamHandler(this); + } + + @Override + public void onListen(Object arguments, EventChannel.EventSink events) { + sink = events; + } + + @Override + public void onCancel(Object arguments) { + sink = null; + } + + void onError(String errorCode, @Nullable String errorMessage, @Nullable Object errorDetails) { + if (sink == null) return; + sink.error(errorCode, errorMessage, errorDetails); + } + + void onSuccess() { + if (sink == null) return; + Map body = new HashMap<>(); + body.put("status", "success"); + sink.success(gson.toJson(body)); + } + + void onStart() { + if (sink == null) return; + Map body = new HashMap<>(); + body.put("status", "start"); + sink.success(gson.toJson(body)); + } + + void onProgress(double progress) { + if (sink == null) return; + Map body = new HashMap<>(); + body.put("status", "progress"); + body.put("progress", progress); + sink.success(gson.toJson(body)); + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/OfflineManagerUtils.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/OfflineManagerUtils.java new file mode 100644 index 00000000..b0585a19 --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/OfflineManagerUtils.java @@ -0,0 +1,337 @@ +package org.maplibre.maplibregl; + +import android.content.Context; +import android.util.Log; +import com.google.gson.Gson; +import org.maplibre.android.geometry.LatLng; +import org.maplibre.android.geometry.LatLngBounds; +import org.maplibre.android.offline.OfflineManager; +import org.maplibre.android.offline.OfflineRegion; +import org.maplibre.android.offline.OfflineRegionDefinition; +import org.maplibre.android.offline.OfflineRegionError; +import org.maplibre.android.offline.OfflineRegionStatus; +import org.maplibre.android.offline.OfflineTilePyramidRegionDefinition; +import io.flutter.plugin.common.MethodChannel; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +abstract class OfflineManagerUtils { + private static final String TAG = "OfflineManagerUtils"; + + static void mergeRegions(MethodChannel.Result result, Context context, String path) { + OfflineManager.Companion.getInstance(context) + .mergeOfflineRegions( + path, + new OfflineManager.MergeOfflineRegionsCallback() { + public void onMerge(OfflineRegion[] offlineRegions) { + if (result == null) return; + List> regionsArgs = new ArrayList<>(); + for (OfflineRegion offlineRegion : offlineRegions) { + regionsArgs.add(offlineRegionToMap(offlineRegion)); + } + String json = new Gson().toJson(regionsArgs); + result.success(json); + } + + public void onError(String error) { + if (result == null) return; + result.error("mergeOfflineRegions Error", error, null); + } + }); + } + + static void setOfflineTileCountLimit(MethodChannel.Result result, Context context, long limit) { + OfflineManager.Companion.getInstance(context).setOfflineMapboxTileCountLimit(limit); + result.success(null); + } + + static void downloadRegion( + MethodChannel.Result result, + Context context, + Map definitionMap, + Map metadataMap, + OfflineChannelHandlerImpl channelHandler) { + float pixelDensity = context.getResources().getDisplayMetrics().density; + OfflineRegionDefinition definition = mapToRegionDefinition(definitionMap, pixelDensity); + String metadata = "{}"; + if (metadataMap != null) { + metadata = new Gson().toJson(metadataMap); + } + AtomicBoolean isComplete = new AtomicBoolean(false); + // Download region + OfflineManager.Companion.getInstance(context) + .createOfflineRegion( + definition, + metadata.getBytes(), + new OfflineManager.CreateOfflineRegionCallback() { + private OfflineRegion _offlineRegion; + + @Override + public void onCreate(OfflineRegion offlineRegion) { + Map regionData = offlineRegionToMap(offlineRegion); + result.success(new Gson().toJson(regionData)); + + _offlineRegion = offlineRegion; + // Observe downloading state + OfflineRegion.OfflineRegionObserver observer = + new OfflineRegion.OfflineRegionObserver() { + @Override + public void onStatusChanged(OfflineRegionStatus status) { + // Calculate progress of + // downloading + double progress = + calculateDownloadingProgress( + status.getRequiredResourceCount(), + status.getCompletedResourceCount()); + // Check if downloading is + // complete + if (status.isComplete()) { + Log.i(TAG, "Region " + "downloaded " + "successfully."); + // Reset downloading state + _offlineRegion.setDownloadState(OfflineRegion.STATE_INACTIVE); + // This can be called + // multiple times, and + // result can be called + // only once, + // so there is need to + // prevent it + if (isComplete.get()) return; + isComplete.set(true); + channelHandler.onSuccess(); + } else { + Log.i(TAG, "Region " + "download " + "progress = " + progress); + channelHandler.onProgress(progress); + } + } + + @Override + public void onError(OfflineRegionError error) { + Log.e(TAG, "onError reason: " + error.getReason()); + Log.e(TAG, "onError message: " + error.getMessage()); + // Reset downloading state + _offlineRegion.setDownloadState(OfflineRegion.STATE_INACTIVE); + isComplete.set(true); + channelHandler.onError( + "Downloading error", error.getMessage(), error.getReason()); + } + + @Override + public void mapboxTileCountLimitExceeded(long limit) { + Log.e(TAG, "MapLibre tile count" + " limit exceeded: " + limit); + // Reset downloading state + _offlineRegion.setDownloadState(OfflineRegion.STATE_INACTIVE); + isComplete.set(true); + channelHandler.onError( + "mapboxTileCountLimitExceeded", + "MapLibre tile count " + "limit " + "exceeded: " + limit, + null); + // MapLibre even after crash + // and not downloading fully + // region still keeps part + // of it in database, so we + // have to remove it + deleteRegion(null, context, _offlineRegion.getId()); + } + }; + + _offlineRegion.setObserver(observer); + + // Start downloading region + _offlineRegion.setDownloadState(OfflineRegion.STATE_ACTIVE); + channelHandler.onStart(); + } + + /** + * This will be call if given region definition is invalid + * + * @param error + */ + @Override + public void onError(String error) { + Log.e(TAG, "Error: " + error); + // Reset downloading state + _offlineRegion.setDownloadState(OfflineRegion.STATE_INACTIVE); + channelHandler.onError("mapboxInvalidRegionDefinition", error, null); + result.error("mapboxInvalidRegionDefinition", error, null); + } + }); + } + + static void regionsList(MethodChannel.Result result, Context context) { + OfflineManager.Companion.getInstance(context) + .listOfflineRegions( + new OfflineManager.ListOfflineRegionsCallback() { + @Override + public void onList(OfflineRegion[] offlineRegions) { + List> regionsArgs = new ArrayList<>(); + for (OfflineRegion offlineRegion : offlineRegions) { + regionsArgs.add(offlineRegionToMap(offlineRegion)); + } + result.success(new Gson().toJson(regionsArgs)); + } + + @Override + public void onError(String error) { + result.error("RegionListError", error, null); + } + }); + } + + static void updateRegionMetadata( + MethodChannel.Result result, Context context, long id, Map metadataMap) { + OfflineManager.Companion.getInstance(context) + .listOfflineRegions( + new OfflineManager.ListOfflineRegionsCallback() { + @Override + public void onList(OfflineRegion[] offlineRegions) { + for (OfflineRegion offlineRegion : offlineRegions) { + if (offlineRegion.getId() != id) continue; + + String metadata = "{}"; + if (metadataMap != null) { + metadata = new Gson().toJson(metadataMap); + } + offlineRegion.updateMetadata( + metadata.getBytes(), + new OfflineRegion.OfflineRegionUpdateMetadataCallback() { + @Override + public void onUpdate(byte[] metadataBytes) { + Map regionData = offlineRegionToMap(offlineRegion); + regionData.put("metadata", metadataBytesToMap(metadataBytes)); + + if (result == null) return; + result.success(new Gson().toJson(regionData)); + } + + @Override + public void onError(String error) { + if (result == null) return; + result.error("UpdateMetadataError", error, null); + } + }); + return; + } + if (result == null) return; + result.error( + "UpdateMetadataError", + "There is no " + "region with given id to " + "update.", + null); + } + + @Override + public void onError(String error) { + if (result == null) return; + result.error("RegionListError", error, null); + } + }); + } + + static void deleteRegion(MethodChannel.Result result, Context context, long id) { + OfflineManager.Companion.getInstance(context) + .listOfflineRegions( + new OfflineManager.ListOfflineRegionsCallback() { + @Override + public void onList(OfflineRegion[] offlineRegions) { + for (OfflineRegion offlineRegion : offlineRegions) { + if (offlineRegion.getId() != id) continue; + + offlineRegion.delete( + new OfflineRegion.OfflineRegionDeleteCallback() { + @Override + public void onDelete() { + if (result == null) return; + result.success(null); + } + + @Override + public void onError(String error) { + if (result == null) return; + result.error("DeleteRegionError", error, null); + } + }); + return; + } + if (result == null) return; + result.error( + "DeleteRegionError", + "There is no " + "region with given id to " + "delete.", + null); + } + + @Override + public void onError(String error) { + if (result == null) return; + result.error("RegionListError", error, null); + } + }); + } + + private static double calculateDownloadingProgress( + long requiredResourceCount, long completedResourceCount) { + return requiredResourceCount > 0 + ? (100.0 * completedResourceCount / requiredResourceCount) + : 0.0; + } + + private static OfflineRegionDefinition mapToRegionDefinition( + Map map, float pixelDensity) { + for (Map.Entry entry : map.entrySet()) { + Log.d(TAG, entry.getKey()); + Log.d(TAG, entry.getValue().toString()); + } + // Create a bounding box for the offline region + return new OfflineTilePyramidRegionDefinition( + (String) map.get("mapStyleUrl"), + listToBounds((List>) map.get("bounds")), + ((Number) map.get("minZoom")).doubleValue(), + ((Number) map.get("maxZoom")).doubleValue(), + pixelDensity, + (Boolean) map.get("includeIdeographs")); + } + + private static LatLngBounds listToBounds(List> bounds) { + return new LatLngBounds.Builder() + .include(new LatLng(bounds.get(1).get(0), bounds.get(1).get(1))) // Northeast + .include(new LatLng(bounds.get(0).get(0), bounds.get(0).get(1))) // Southwest + .build(); + } + + private static Map offlineRegionToMap(OfflineRegion region) { + Map result = new HashMap(); + result.put("id", region.getId()); + result.put("definition", offlineRegionDefinitionToMap(region.getDefinition())); + result.put("metadata", metadataBytesToMap(region.getMetadata())); + return result; + } + + private static Map offlineRegionDefinitionToMap( + OfflineRegionDefinition definition) { + Map result = new HashMap(); + result.put("mapStyleUrl", definition.getStyleURL()); + result.put("bounds", boundsToList(definition.getBounds())); + result.put("minZoom", definition.getMinZoom()); + result.put("maxZoom", definition.getMaxZoom()); + result.put("includeIdeographs", definition.getIncludeIdeographs()); + return result; + } + + private static List> boundsToList(LatLngBounds bounds) { + List> boundsList = new ArrayList<>(); + List northeast = Arrays.asList(bounds.getLatNorth(), bounds.getLonEast()); + List southwest = Arrays.asList(bounds.getLatSouth(), bounds.getLonWest()); + boundsList.add(southwest); + boundsList.add(northeast); + return boundsList; + } + + private static Map metadataBytesToMap(byte[] metadataBytes) { + if (metadataBytes != null) { + return new Gson().fromJson(new String(metadataBytes), HashMap.class); + } + return new HashMap(); + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/SourcePropertyConverter.java b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/SourcePropertyConverter.java new file mode 100644 index 00000000..de750dd0 --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/SourcePropertyConverter.java @@ -0,0 +1,232 @@ +package org.maplibre.maplibregl; + +import android.net.Uri; +import com.google.gson.Gson; +import org.maplibre.geojson.FeatureCollection; +import org.maplibre.android.geometry.LatLng; +import org.maplibre.android.geometry.LatLngQuad; +import org.maplibre.android.maps.Style; +import org.maplibre.android.style.sources.GeoJsonOptions; +import org.maplibre.android.style.sources.GeoJsonSource; +import org.maplibre.android.style.sources.ImageSource; +import org.maplibre.android.style.sources.RasterDemSource; +import org.maplibre.android.style.sources.RasterSource; +import org.maplibre.android.style.sources.Source; +import org.maplibre.android.style.sources.TileSet; +import org.maplibre.android.style.sources.VectorSource; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +class SourcePropertyConverter { + private static final String TAG = "SourcePropertyConverter"; + + static TileSet buildTileset(Map data) { + final Object tiles = data.get("tiles"); + + // options are only valid with tiles + if (tiles == null) { + return null; + } + + final TileSet tileSet = + new TileSet("2.1.0", (String[]) Convert.toList(tiles).toArray(new String[0])); + + final Object bounds = data.get("bounds"); + if (bounds != null) { + List boundsFloat = new ArrayList(); + for (Object item : Convert.toList(bounds)) { + boundsFloat.add(Convert.toFloat(item)); + } + tileSet.setBounds(boundsFloat.toArray(new Float[0])); + } + + final Object scheme = data.get("scheme"); + if (scheme != null) { + tileSet.setScheme(Convert.toString(scheme)); + } + + final Object minzoom = data.get("minzoom"); + if (minzoom != null) { + tileSet.setMinZoom(Convert.toFloat(minzoom)); + } + + final Object maxzoom = data.get("maxzoom"); + if (maxzoom != null) { + tileSet.setMaxZoom(Convert.toFloat(maxzoom)); + } + + final Object attribution = data.get("attribution"); + if (attribution != null) { + tileSet.setAttribution(Convert.toString(attribution)); + } + return tileSet; + } + + static GeoJsonOptions buildGeojsonOptions(Map data) { + GeoJsonOptions options = new GeoJsonOptions(); + + final Object buffer = data.get("buffer"); + if (buffer != null) { + options = options.withBuffer(Convert.toInt(buffer)); + } + + final Object cluster = data.get("cluster"); + if (cluster != null) { + options = options.withCluster(Convert.toBoolean(cluster)); + } + + final Object clusterMaxZoom = data.get("clusterMaxZoom"); + if (clusterMaxZoom != null) { + options = options.withClusterMaxZoom(Convert.toInt(clusterMaxZoom)); + } + + final Object clusterRadius = data.get("clusterRadius"); + if (clusterRadius != null) { + options = options.withClusterRadius(Convert.toInt(clusterRadius)); + } + + final Object lineMetrics = data.get("lineMetrics"); + if (lineMetrics != null) { + options = options.withLineMetrics(Convert.toBoolean(lineMetrics)); + } + + final Object maxZoom = data.get("maxZoom"); + if (maxZoom != null) { + options = options.withMaxZoom(Convert.toInt(maxZoom)); + } + + final Object minZoom = data.get("minZoom"); + if (minZoom != null) { + options = options.withMinZoom(Convert.toInt(minZoom)); + } + + final Object tolerance = data.get("tolerance"); + if (tolerance != null) { + options = options.withTolerance(Convert.toFloat(tolerance)); + } + return options; + } + + static GeoJsonSource buildGeojsonSource(String id, Map properties) { + final Object data = properties.get("data"); + final GeoJsonOptions options = buildGeojsonOptions(properties); + if (data != null) { + if (data instanceof String) { + try { + final URI uri = new URI(Convert.toString(data)); + return new GeoJsonSource(id, uri, options); + } catch (URISyntaxException e) { + } + } else { + Gson gson = new Gson(); + String geojson = gson.toJson(data); + final FeatureCollection featureCollection = FeatureCollection.fromJson(geojson); + return new GeoJsonSource(id, featureCollection, options); + } + } + return null; + } + + static ImageSource buildImageSource(String id, Map properties) { + final Object url = properties.get("url"); + List coordinates = Convert.toLatLngList(properties.get("coordinates"), true); + final LatLngQuad quad = + new LatLngQuad( + coordinates.get(0), coordinates.get(1), coordinates.get(2), coordinates.get(3)); + try { + final URI uri = new URI(Convert.toString(url)); + return new ImageSource(id, quad, uri); + } catch (URISyntaxException e) { + } + return null; + } + + static VectorSource buildVectorSource(String id, Map properties) { + final Object url = properties.get("url"); + if (url != null) { + final Uri uri = Uri.parse(Convert.toString(url)); + + if (uri != null) { + return new VectorSource(id, uri); + } + return null; + } + + final TileSet tileSet = buildTileset(properties); + return tileSet != null ? new VectorSource(id, tileSet) : null; + } + + static RasterSource buildRasterSource(String id, Map properties) { + final Object url = properties.get("url"); + final Object tileSizeObj = properties.get("tileSize"); + if (url != null) { + final String uri = Convert.toString(url); + if (tileSizeObj != null) { + final int tileSize = Convert.toInt(tileSizeObj); + return new RasterSource(id, uri, tileSize); + } else { + return new RasterSource(id, uri); + } + } + + final TileSet tileSet = buildTileset(properties); + if (tileSet != null) { + if (tileSizeObj != null) { + final int tileSize = Convert.toInt(tileSizeObj); + return new RasterSource(id, tileSet, tileSize); + } else { + return new RasterSource(id, tileSet); + } + } else { + return null; + } + } + + static RasterDemSource buildRasterDemSource(String id, Map properties) { + final Object url = properties.get("url"); + if (url != null) { + try { + final URI uri = new URI(Convert.toString(url)); + return new RasterDemSource(id, uri); + } catch (URISyntaxException e) { + } + } + + final TileSet tileSet = buildTileset(properties); + return tileSet != null ? new RasterDemSource(id, tileSet) : null; + } + + static void addSource(String id, Map properties, Style style) { + final Object type = properties.get("type"); + Source source = null; + + if (type != null) { + switch (Convert.toString(type)) { + case "vector": + source = buildVectorSource(id, properties); + break; + case "raster": + source = buildRasterSource(id, properties); + break; + case "raster-dem": + source = buildRasterDemSource(id, properties); + break; + case "image": + source = buildImageSource(id, properties); + break; + case "geojson": + source = buildGeojsonSource(id, properties); + break; + default: + // unsupported source type + } + } + + if (source != null) { + style.addSource(source); + } + } +} diff --git a/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/setMapLanguage.kt b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/setMapLanguage.kt new file mode 100644 index 00000000..bfcd6603 --- /dev/null +++ b/third_party/maplibre_gl/android/src/main/java/org/maplibre/maplibregl/setMapLanguage.kt @@ -0,0 +1,31 @@ +@file:JvmName("MapLibreMapUtils") + +package org.maplibre.maplibregl + +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.style.expressions.Expression +import org.maplibre.android.style.layers.PropertyFactory +import org.maplibre.android.style.layers.SymbolLayer + +fun MapLibreMap.setMapLanguage(language: String) { + val layers = this.style?.layers ?: emptyList() + + val languageRegex = Regex("(name:[a-z]+)") + + val symbolLayers = layers.filterIsInstance() + + for (layer in symbolLayers) { + // continue when there is no current expression + val expression = layer.textField.expression ?: continue + + // We could skip the current iteration, whenever there is not current language. + if (!expression.toString().contains(languageRegex)) { + continue + } + + val properties = + "[\"coalesce\", [\"get\",\"name:$language\"],[\"get\",\"name:latin\"],[\"get\",\"name\"]]" + + layer.setProperties(PropertyFactory.textField(Expression.raw(properties))) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl.podspec b/third_party/maplibre_gl/ios/maplibre_gl.podspec new file mode 100644 index 00000000..a322e790 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html +# +Pod::Spec.new do |s| + s.name = 'maplibre_gl' + s.version = '0.25.0' + s.summary = 'MapLibre GL Flutter plugin' + s.description = <<-DESC +MapLibre GL Flutter plugin. + DESC + s.homepage = 'https://maplibre.org' + s.license = { :file => '../LICENSE' } + s.author = { 'MapLibre' => 'info@maplibre.org' } + s.source = { :path => '.' } + s.source_files = 'maplibre_gl/Sources/maplibre_gl/**/*' + s.dependency 'Flutter' + # When updating the dependency version, + # make sure to also update the version in Package.swift. + s.dependency 'MapLibre', '6.19.1' + s.swift_version = '5.0' + s.ios.deployment_target = '13.0' +end + diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Package.swift b/third_party/maplibre_gl/ios/maplibre_gl/Package.swift new file mode 100644 index 00000000..810dbe82 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "maplibre_gl", + platforms: [ + .iOS("13.0"), + ], + products: [ + .library(name: "maplibre-gl", targets: ["maplibre_gl"]) + ], + dependencies: [ + // When updating the dependency version, + // make sure to also update the version in maplibre_gl.podspec. + .package(url: "https://github.com/maplibre/maplibre-gl-native-distribution.git", exact: "6.19.1"), + ], + targets: [ + .target( + name: "maplibre_gl", + dependencies: [ + .product(name: "MapLibre", package: "maplibre-gl-native-distribution") + ], + cSettings: [ + .headerSearchPath("include/maplibre_gl") + ] + ) + ] +) diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Constants.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Constants.swift new file mode 100644 index 00000000..1a548ce0 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Constants.swift @@ -0,0 +1,51 @@ +import MapLibre + +/* + * The mapping is based on the values defined here: + * https://docs.mapbox.com/android/api/map-sdk/8.4.0/constant-values.html + */ + +class Constants { + static let symbolIconAnchorMapping = [ + "center": MLNIconAnchor.center, + "left": MLNIconAnchor.left, + "right": MLNIconAnchor.right, + "top": MLNIconAnchor.top, + "bottom": MLNIconAnchor.bottom, + "top-left": MLNIconAnchor.topLeft, + "top-right": MLNIconAnchor.topRight, + "bottom-left": MLNIconAnchor.bottomLeft, + "bottom-right": MLNIconAnchor.bottomRight, + ] + + static let symbolTextJustificationMapping = [ + "auto": MLNTextJustification.auto, + "center": MLNTextJustification.center, + "left": MLNTextJustification.left, + "right": MLNTextJustification.right, + ] + + static let symbolTextAnchorMapping = [ + "center": MLNTextAnchor.center, + "left": MLNTextAnchor.left, + "right": MLNTextAnchor.right, + "top": MLNTextAnchor.top, + "bottom": MLNTextAnchor.bottom, + "top-left": MLNTextAnchor.topLeft, + "top-right": MLNTextAnchor.topRight, + "bottom-left": MLNTextAnchor.bottomLeft, + "bottom-right": MLNTextAnchor.bottomRight, + ] + + static let symbolTextTransformationMapping = [ + "none": MLNTextTransform.none, + "lowercase": MLNTextTransform.lowercase, + "uppercase": MLNTextTransform.uppercase, + ] + + static let lineJoinMapping = [ + "bevel": MLNLineJoin.bevel, + "miter": MLNLineJoin.miter, + "round": MLNLineJoin.round, + ] +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Convert.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Convert.swift new file mode 100644 index 00000000..b7fb0c04 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Convert.swift @@ -0,0 +1,229 @@ +import MapLibre + +class Convert { + class func interpretMapLibreMapOptions(options: Any?, delegate: MapLibreMapOptionsSink) { + guard let options = options as? [String: Any] else { return } + if let cameraTargetBounds = options["cameraTargetBounds"] as? [Any?] { + // Handle both [[[Double]]] and [nil] (for unbounded) + if let boundsArray = cameraTargetBounds[0] as? [[Double]] { + let bounds = MLNCoordinateBounds.fromArray(boundsArray) + delegate.setCameraTargetBounds(bounds: bounds) + } else { + // Unbounded - clear the bounds restriction + delegate.setCameraTargetBounds(bounds: nil) + } + } + if let compassEnabled = options["compassEnabled"] as? Bool { + delegate.setCompassEnabled(compassEnabled: compassEnabled) + } + if let minMaxZoomPreference = options["minMaxZoomPreference"] as? [Any] { + // Handle both [Double] and [NSNull] (for unbounded zoom) + let minZoom: Double? = (minMaxZoomPreference[0] is NSNull) ? nil : minMaxZoomPreference[0] as? Double + let maxZoom: Double? = (minMaxZoomPreference[1] is NSNull) ? nil : minMaxZoomPreference[1] as? Double + + delegate.setMinMaxZoomPreference( + min: minZoom, + max: maxZoom + ) + } + if let styleString = options["styleString"] as? String { + delegate.setStyleString(styleString: styleString) + } + if let rotateGesturesEnabled = options["rotateGesturesEnabled"] as? Bool { + delegate.setRotateGesturesEnabled(rotateGesturesEnabled: rotateGesturesEnabled) + } + if let scrollGesturesEnabled = options["scrollGesturesEnabled"] as? Bool { + delegate.setScrollGesturesEnabled(scrollGesturesEnabled: scrollGesturesEnabled) + } + if let tiltGesturesEnabled = options["tiltGesturesEnabled"] as? Bool { + delegate.setTiltGesturesEnabled(tiltGesturesEnabled: tiltGesturesEnabled) + } + if let trackCameraPosition = options["trackCameraPosition"] as? Bool { + delegate.setTrackCameraPosition(trackCameraPosition: trackCameraPosition) + } + if let zoomGesturesEnabled = options["zoomGesturesEnabled"] as? Bool { + delegate.setZoomGesturesEnabled(zoomGesturesEnabled: zoomGesturesEnabled) + } + if let myLocationEnabled = options["myLocationEnabled"] as? Bool { + delegate.setMyLocationEnabled(myLocationEnabled: myLocationEnabled) + } + if let myLocationTrackingMode = options["myLocationTrackingMode"] as? UInt, + let trackingMode = MLNUserTrackingMode(rawValue: myLocationTrackingMode) + { + delegate.setMyLocationTrackingMode(myLocationTrackingMode: trackingMode) + } + if let myLocationRenderMode = options["myLocationRenderMode"] as? Int, + let renderMode = MyLocationRenderMode(rawValue: myLocationRenderMode) + { + delegate.setMyLocationRenderMode(myLocationRenderMode: renderMode) + } + if let logoEnabled = options["logoEnabled"] as? Bool { + delegate.setLogoEnabled(logoEnabled: logoEnabled) + } + if let logoViewPosition = options["logoViewPosition"] as? UInt, + let position = MLNOrnamentPosition(rawValue: logoViewPosition) + { + delegate.setLogoViewPosition(position: position) + } + if let logoViewMargins = options["logoViewMargins"] as? [Double] { + delegate.setLogoViewMargins(x: logoViewMargins[0], y: logoViewMargins[1]) + } + if let compassViewPosition = options["compassViewPosition"] as? UInt, + let position = MLNOrnamentPosition(rawValue: compassViewPosition) + { + delegate.setCompassViewPosition(position: position) + } + if let compassViewMargins = options["compassViewMargins"] as? [Double] { + delegate.setCompassViewMargins(x: compassViewMargins[0], y: compassViewMargins[1]) + } + if let attributionButtonMargins = options["attributionButtonMargins"] as? [Double] { + delegate.setAttributionButtonMargins( + x: attributionButtonMargins[0], + y: attributionButtonMargins[1] + ) + } + if let attributionButtonPosition = options["attributionButtonPosition"] as? UInt, + let position = MLNOrnamentPosition(rawValue: attributionButtonPosition) + { + delegate.setAttributionButtonPosition(position: position) + } + } + + class func parseLatLngBoundsPadding(_ cameraUpdate: [Any]) -> UIEdgeInsets? { + guard let methodName = cameraUpdate[0] as? String else { return nil } + + if(methodName != "newLatLngBounds") { + return nil + } + + guard let paddingLeft = cameraUpdate[2] as? CGFloat else { return nil } + guard let paddingTop = cameraUpdate[3] as? CGFloat else { return nil } + guard let paddingRight = cameraUpdate[4] as? CGFloat else { return nil } + guard let paddingBottom = cameraUpdate[5] as? CGFloat else { return nil } + + return UIEdgeInsets(top: paddingTop, left: paddingLeft, bottom: paddingBottom, right: paddingRight) + } + + class func parseCameraUpdate(cameraUpdate: [Any], mapView: MLNMapView) -> MLNMapCamera? { + guard let type = cameraUpdate[0] as? String else { return nil } + switch type { + case "newCameraPosition": + guard let cameraPosition = cameraUpdate[1] as? [String: Any] else { return nil } + return MLNMapCamera.fromDict(cameraPosition, mapView: mapView) + case "newLatLng": + guard let coordinate = cameraUpdate[1] as? [Double] else { return nil } + let camera = mapView.camera + camera.centerCoordinate = CLLocationCoordinate2D.fromArray(coordinate) + return camera + case "newLatLngBounds": + guard let bounds = cameraUpdate[1] as? [[Double]] else { return nil } + + if let padding = parseLatLngBoundsPadding(cameraUpdate) { + return mapView.cameraThatFitsCoordinateBounds( + MLNCoordinateBounds.fromArray(bounds), + edgePadding: padding + ) + } + + return mapView.cameraThatFitsCoordinateBounds(MLNCoordinateBounds.fromArray(bounds)) + + case "newLatLngZoom": + guard let coordinate = cameraUpdate[1] as? [Double] else { return nil } + guard let zoom = cameraUpdate[2] as? Double else { return nil } + let camera = mapView.camera + camera.centerCoordinate = CLLocationCoordinate2D.fromArray(coordinate) + let altitude = getAltitude(zoom: zoom, mapView: mapView) + return MLNMapCamera( + lookingAtCenter: camera.centerCoordinate, + altitude: altitude, + pitch: camera.pitch, + heading: camera.heading + ) + case "scrollBy": + guard let x = cameraUpdate[1] as? CGFloat else { return nil } + guard let y = cameraUpdate[2] as? CGFloat else { return nil } + let camera = mapView.camera + let mapPoint = mapView.convert(camera.centerCoordinate, toPointTo: mapView) + let movedPoint = CGPoint(x: mapPoint.x + x, y: mapPoint.y + y) + camera.centerCoordinate = mapView.convert(movedPoint, toCoordinateFrom: mapView) + return camera + case "zoomBy": + guard let zoomBy = cameraUpdate[1] as? Double else { return nil } + let camera = mapView.camera + let zoom = getZoom(mapView: mapView) + let altitude = getAltitude(zoom: zoom + zoomBy, mapView: mapView) + camera.altitude = altitude + if cameraUpdate.count == 2 { + return camera + } else { + guard let point = cameraUpdate[2] as? [CGFloat], + point.count == 2 else { return nil } + let movedPoint = CGPoint(x: point[0], y: point[1]) + camera.centerCoordinate = mapView.convert(movedPoint, toCoordinateFrom: mapView) + return camera + } + case "zoomIn": + let camera = mapView.camera + let zoom = getZoom(mapView: mapView) + let altitude = getAltitude(zoom: zoom + 1, mapView: mapView) + camera.altitude = altitude + return camera + case "zoomOut": + let camera = mapView.camera + let zoom = getZoom(mapView: mapView) + let altitude = getAltitude(zoom: zoom - 1, mapView: mapView) + camera.altitude = altitude + return camera + case "zoomTo": + guard let zoom = cameraUpdate[1] as? Double else { return nil } + let camera = mapView.camera + let altitude = getAltitude(zoom: zoom, mapView: mapView) + camera.altitude = altitude + return camera + case "bearingTo": + guard let bearing = cameraUpdate[1] as? Double else { return nil } + let camera = mapView.camera + camera.heading = bearing + return camera + case "tiltTo": + guard let tilt = cameraUpdate[1] as? CGFloat else { return nil } + let camera = mapView.camera + camera.pitch = tilt + return camera + default: + print("\(type) not implemented!") + } + return nil + } + + class func getZoom(mapView: MLNMapView) -> Double { + return MLNZoomLevelForAltitude( + mapView.camera.altitude, + mapView.camera.pitch, + mapView.camera.centerCoordinate.latitude, + mapView.frame.size + ) + } + + class func getAltitude(zoom: Double, mapView: MLNMapView) -> Double { + return MLNAltitudeForZoomLevel( + zoom, + mapView.camera.pitch, + mapView.camera.centerCoordinate.latitude, + mapView.frame.size + ) + } + + class func getCoordinates(options: Any?) -> [CLLocationCoordinate2D] { + var coordinates: [CLLocationCoordinate2D] = [] + + if let options = options as? [String: Any], + let geometry = options["geometry"] as? [[Double]], geometry.count > 0 + { + for coordinate in geometry { + coordinates.append(CLLocationCoordinate2DMake(coordinate[0], coordinate[1])) + } + } + return coordinates + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Enums.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Enums.swift new file mode 100644 index 00000000..6d621d34 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Enums.swift @@ -0,0 +1,3 @@ +enum MyLocationRenderMode: Int { + case Normal, Compass, Gps +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Extensions.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Extensions.swift new file mode 100644 index 00000000..722be04a --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/Extensions.swift @@ -0,0 +1,148 @@ +import MapLibre + +extension MLNMapCamera { + func toDict(mapView: MLNMapView) -> [String: Any] { + let zoom = MLNZoomLevelForAltitude( + altitude, + pitch, + centerCoordinate.latitude, + mapView.frame.size + ) + return ["bearing": heading, + "target": centerCoordinate.toArray(), + "tilt": pitch, + "zoom": zoom] + } + + static func fromDict(_ dict: [String: Any], mapView: MLNMapView) -> MLNMapCamera? { + guard let target = dict["target"] as? [Double], + let zoom = dict["zoom"] as? Double, + let tilt = dict["tilt"] as? CGFloat, + let bearing = dict["bearing"] as? Double else { return nil } + let location = CLLocationCoordinate2D.fromArray(target) + let altitude = MLNAltitudeForZoomLevel(zoom, tilt, location.latitude, mapView.frame.size) + return MLNMapCamera( + lookingAtCenter: location, + altitude: altitude, + pitch: tilt, + heading: bearing + ) + } +} + +extension CLLocation { + func toDict() -> [String: Any]? { + return ["position": coordinate.toArray(), + "altitude": altitude, + "bearing": course, + "speed": speed, + "horizontalAccuracy": horizontalAccuracy, + "verticalAccuracy": verticalAccuracy, + "timestamp": Int(timestamp.timeIntervalSince1970 * 1000)] + } +} + +extension CLHeading { + func toDict() -> [String: Any]? { + return ["magneticHeading": magneticHeading, + "trueHeading": trueHeading, + "headingAccuracy": headingAccuracy, + "x": x, + "y": y, + "z": z, + "timestamp": Int(timestamp.timeIntervalSince1970 * 1000)] + } +} + +extension CLLocationCoordinate2D { + func toArray() -> [Double] { + return [latitude, longitude] + } + + static func fromArray(_ array: [Double]) -> CLLocationCoordinate2D { + return CLLocationCoordinate2D(latitude: array[0], longitude: array[1]) + } +} + +extension MLNCoordinateBounds { + func toArray() -> [[Double]] { + return [sw.toArray(), ne.toArray()] + } + + static func fromArray(_ array: [[Double]]) -> MLNCoordinateBounds { + let southwest = CLLocationCoordinate2D.fromArray(array[0]) + let northeast = CLLocationCoordinate2D.fromArray(array[1]) + return MLNCoordinateBounds(sw: southwest, ne: northeast) + } +} + +extension UIImage { + static func loadFromFile(imagePath: String, imageName: String) -> UIImage? { + // Add the trailing slash in path if missing. + let path = imagePath.hasSuffix("/") ? imagePath : "\(imagePath)/" + // Build scale dependant image path. + var scale = UIScreen.main.scale + var absolutePath = "\(path)\(scale)x/\(imageName)" + // Check if the image exists, if not try a an unscaled path. + if Bundle.main.path(forResource: absolutePath, ofType: nil) == nil { + absolutePath = "\(path)\(imageName)" + } else { + // found asset with higher resolution - increase scale even further to compensate + scale *= scale + } + // Load image if it exists. + if let path = Bundle.main.path(forResource: absolutePath, ofType: nil) { + let imageUrl = URL(fileURLWithPath: path) + if let imageData: Data = try? Data(contentsOf: imageUrl), + let image = UIImage(data: imageData, scale: scale) + { + return image + } + } + return nil + } +} + +public extension UIColor { + convenience init?(hexString: String) { + let r, g, b, a: CGFloat + + if hexString.hasPrefix("#") { + let start = hexString.index(hexString.startIndex, offsetBy: 1) + let hexColor = hexString[start...] + + let scanner = Scanner(string: String(hexColor)) + var hexNumber: UInt64 = 0 + + if hexColor.count == 6 { + if scanner.scanHexInt64(&hexNumber) { + r = CGFloat((hexNumber & 0xFF0000) >> 16) / 255 + g = CGFloat((hexNumber & 0x00FF00) >> 8) / 255 + b = CGFloat(hexNumber & 0x0000FF) / 255 + a = 255 + + self.init(red: r, green: g, blue: b, alpha: a) + return + } + } else if hexColor.count == 8 { + if scanner.scanHexInt64(&hexNumber) { + a = CGFloat((hexNumber & 0xFF00_0000) >> 24) / 255 + r = CGFloat((hexNumber & 0x00FF_0000) >> 16) / 255 + g = CGFloat((hexNumber & 0x0000_FF00) >> 8) / 255 + b = CGFloat(hexNumber & 0x0000_00FF) / 255 + + self.init(red: r, green: g, blue: b, alpha: a) + return + } + } + } + + return nil + } +} + +extension Array { + var tail: Array { + return Array(dropFirst()) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/LayerPropertyConverter.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/LayerPropertyConverter.swift new file mode 100644 index 00000000..e80a7272 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/LayerPropertyConverter.swift @@ -0,0 +1,536 @@ +// This file is generated by +// ./scripts/lib/generate.dart + +import MapLibre + +class LayerPropertyConverter { + class func addSymbolProperties(symbolLayer: MLNSymbolStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + + switch propertyName { + case "icon-opacity": + symbolLayer.iconOpacity = expression + case "icon-color": + symbolLayer.iconColor = expression + case "icon-halo-color": + symbolLayer.iconHaloColor = expression + case "icon-halo-width": + symbolLayer.iconHaloWidth = expression + case "icon-halo-blur": + symbolLayer.iconHaloBlur = expression + case "icon-translate": + symbolLayer.iconTranslation = expression + case "icon-translate-anchor": + symbolLayer.iconTranslationAnchor = expression + case "text-opacity": + symbolLayer.textOpacity = expression + case "text-color": + symbolLayer.textColor = expression + case "text-halo-color": + symbolLayer.textHaloColor = expression + case "text-halo-width": + symbolLayer.textHaloWidth = expression + case "text-halo-blur": + symbolLayer.textHaloBlur = expression + case "text-translate": + symbolLayer.textTranslation = expression + case "text-translate-anchor": + symbolLayer.textTranslationAnchor = expression + case "symbol-placement": + symbolLayer.symbolPlacement = expression + case "symbol-spacing": + symbolLayer.symbolSpacing = expression + case "symbol-avoid-edges": + symbolLayer.symbolAvoidsEdges = expression + case "symbol-sort-key": + symbolLayer.symbolSortKey = expression + case "symbol-z-order": + symbolLayer.symbolZOrder = expression + case "icon-allow-overlap": + symbolLayer.iconAllowsOverlap = expression + case "icon-ignore-placement": + symbolLayer.iconIgnoresPlacement = expression + case "icon-optional": + symbolLayer.iconOptional = expression + case "icon-rotation-alignment": + symbolLayer.iconRotationAlignment = expression + case "icon-size": + symbolLayer.iconScale = expression + case "icon-text-fit": + symbolLayer.iconTextFit = expression + case "icon-text-fit-padding": + symbolLayer.iconTextFitPadding = expression + case "icon-image": + symbolLayer.iconImageName = expression + case "icon-rotate": + symbolLayer.iconRotation = expression + case "icon-padding": + symbolLayer.iconPadding = expression + case "icon-keep-upright": + symbolLayer.keepsIconUpright = expression + case "icon-offset": + symbolLayer.iconOffset = expression + case "icon-anchor": + symbolLayer.iconAnchor = expression + case "icon-pitch-alignment": + symbolLayer.iconPitchAlignment = expression + case "text-pitch-alignment": + symbolLayer.textPitchAlignment = expression + case "text-rotation-alignment": + symbolLayer.textRotationAlignment = expression + case "text-field": + symbolLayer.text = expression + case "text-font": + symbolLayer.textFontNames = expression + case "text-size": + symbolLayer.textFontSize = expression + case "text-max-width": + symbolLayer.maximumTextWidth = expression + case "text-line-height": + symbolLayer.textLineHeight = expression + case "text-letter-spacing": + symbolLayer.textLetterSpacing = expression + case "text-justify": + symbolLayer.textJustification = expression + case "text-radial-offset": + symbolLayer.textRadialOffset = expression + case "text-variable-anchor": + symbolLayer.textVariableAnchor = expression + case "text-anchor": + symbolLayer.textAnchor = expression + case "text-max-angle": + symbolLayer.maximumTextAngle = expression + case "text-writing-mode": + symbolLayer.textWritingModes = expression + case "text-rotate": + symbolLayer.textRotation = expression + case "text-padding": + symbolLayer.textPadding = expression + case "text-keep-upright": + symbolLayer.keepsTextUpright = expression + case "text-transform": + symbolLayer.textTransform = expression + case "text-offset": + symbolLayer.textOffset = expression + case "text-allow-overlap": + symbolLayer.textAllowsOverlap = expression + case "text-ignore-placement": + symbolLayer.textIgnoresPlacement = expression + case "text-optional": + symbolLayer.textOptional = expression + case "visibility": + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + symbolLayer.isVisible = trimmedPropertyValue == "visible" + } + + default: + break + } + } + } + + class func addCircleProperties(circleLayer: MLNCircleStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + + switch propertyName { + case "circle-radius": + circleLayer.circleRadius = expression + case "circle-color": + circleLayer.circleColor = expression + case "circle-blur": + circleLayer.circleBlur = expression + case "circle-opacity": + circleLayer.circleOpacity = expression + case "circle-translate": + circleLayer.circleTranslation = expression + case "circle-translate-anchor": + circleLayer.circleTranslationAnchor = expression + case "circle-pitch-scale": + circleLayer.circleScaleAlignment = expression + case "circle-pitch-alignment": + circleLayer.circlePitchAlignment = expression + case "circle-stroke-width": + circleLayer.circleStrokeWidth = expression + case "circle-stroke-color": + circleLayer.circleStrokeColor = expression + case "circle-stroke-opacity": + circleLayer.circleStrokeOpacity = expression + case "circle-sort-key": + circleLayer.circleSortKey = expression + case "visibility": + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + circleLayer.isVisible = trimmedPropertyValue == "visible" + } + + default: + break + } + } + } + + class func addLineProperties(lineLayer: MLNLineStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + + switch propertyName { + case "line-opacity": + lineLayer.lineOpacity = expression + case "line-color": + lineLayer.lineColor = expression + case "line-translate": + lineLayer.lineTranslation = expression + case "line-translate-anchor": + lineLayer.lineTranslationAnchor = expression + case "line-width": + lineLayer.lineWidth = expression + case "line-gap-width": + lineLayer.lineGapWidth = expression + case "line-offset": + lineLayer.lineOffset = expression + case "line-blur": + lineLayer.lineBlur = expression + case "line-dasharray": + lineLayer.lineDashPattern = expression + case "line-pattern": + lineLayer.linePattern = expression + case "line-gradient": + lineLayer.lineGradient = expression + case "line-cap": + lineLayer.lineCap = expression + case "line-join": + lineLayer.lineJoin = expression + case "line-miter-limit": + lineLayer.lineMiterLimit = expression + case "line-round-limit": + lineLayer.lineRoundLimit = expression + case "line-sort-key": + lineLayer.lineSortKey = expression + case "visibility": + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + lineLayer.isVisible = trimmedPropertyValue == "visible" + } + + default: + break + } + } + } + + class func addFillProperties(fillLayer: MLNFillStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + + switch propertyName { + case "fill-antialias": + fillLayer.fillAntialiased = expression + case "fill-opacity": + fillLayer.fillOpacity = expression + case "fill-color": + fillLayer.fillColor = expression + case "fill-outline-color": + fillLayer.fillOutlineColor = expression + case "fill-translate": + fillLayer.fillTranslation = expression + case "fill-translate-anchor": + fillLayer.fillTranslationAnchor = expression + case "fill-pattern": + fillLayer.fillPattern = expression + case "fill-sort-key": + fillLayer.fillSortKey = expression + case "visibility": + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + fillLayer.isVisible = trimmedPropertyValue == "visible" + } + + default: + break + } + } + } + + class func addFillExtrusionProperties(fillExtrusionLayer: MLNFillExtrusionStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + + switch propertyName { + case "fill-extrusion-opacity": + fillExtrusionLayer.fillExtrusionOpacity = expression + case "fill-extrusion-color": + fillExtrusionLayer.fillExtrusionColor = expression + case "fill-extrusion-translate": + fillExtrusionLayer.fillExtrusionTranslation = expression + case "fill-extrusion-translate-anchor": + fillExtrusionLayer.fillExtrusionTranslationAnchor = expression + case "fill-extrusion-pattern": + fillExtrusionLayer.fillExtrusionPattern = expression + case "fill-extrusion-height": + fillExtrusionLayer.fillExtrusionHeight = expression + case "fill-extrusion-base": + fillExtrusionLayer.fillExtrusionBase = expression + case "fill-extrusion-vertical-gradient": + fillExtrusionLayer.fillExtrusionHasVerticalGradient = expression + case "visibility": + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + fillExtrusionLayer.isVisible = trimmedPropertyValue == "visible" + } + + default: + break + } + } + } + + class func addRasterProperties(rasterLayer: MLNRasterStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + + switch propertyName { + case "raster-opacity": + rasterLayer.rasterOpacity = expression + case "raster-hue-rotate": + rasterLayer.rasterHueRotation = expression + case "raster-brightness-min": + rasterLayer.minimumRasterBrightness = expression + case "raster-brightness-max": + rasterLayer.maximumRasterBrightness = expression + case "raster-saturation": + rasterLayer.rasterSaturation = expression + case "raster-contrast": + rasterLayer.rasterContrast = expression + case "raster-resampling": + rasterLayer.rasterResamplingMode = expression + case "raster-fade-duration": + rasterLayer.rasterFadeDuration = expression + case "visibility": + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + rasterLayer.isVisible = trimmedPropertyValue == "visible" + } + + default: + break + } + } + } + + class func addHillshadeProperties(hillshadeLayer: MLNHillshadeStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + + switch propertyName { + case "hillshade-illumination-direction": + hillshadeLayer.hillshadeIlluminationDirection = expression + case "hillshade-illumination-anchor": + hillshadeLayer.hillshadeIlluminationAnchor = expression + case "hillshade-exaggeration": + hillshadeLayer.hillshadeExaggeration = expression + case "hillshade-shadow-color": + hillshadeLayer.hillshadeShadowColor = expression + case "hillshade-highlight-color": + hillshadeLayer.hillshadeHighlightColor = expression + case "hillshade-accent-color": + hillshadeLayer.hillshadeAccentColor = expression + case "visibility": + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + hillshadeLayer.isVisible = trimmedPropertyValue == "visible" + } + + default: + break + } + } + } + + class func addHeatmapProperties(heatmapLayer: MLNHeatmapStyleLayer, properties: [String: String]) { + for (propertyName, propertyValue) in properties { + // Check if the value is explicitly null to clear the property + let trimmedValue = propertyValue.trimmingCharacters(in: .whitespaces) + + // Prepare expression: nil for "null", parsed for valid values, skip for invalid + var expression: NSExpression? + if trimmedValue == "null" { + expression = nil + } else { + guard let expr = interpretExpression(propertyName: propertyName, expression: propertyValue) else { + continue + } + expression = expr + } + + switch propertyName { + case "heatmap-radius": + heatmapLayer.heatmapRadius = expression + case "heatmap-weight": + heatmapLayer.heatmapWeight = expression + case "heatmap-intensity": + heatmapLayer.heatmapIntensity = expression + case "heatmap-color": + heatmapLayer.heatmapColor = expression + case "heatmap-opacity": + heatmapLayer.heatmapOpacity = expression + case "visibility": + if trimmedValue != "null" { + let trimmedPropertyValue = propertyValue.trimmingCharacters(in: .init(charactersIn: "\"")) + heatmapLayer.isVisible = trimmedPropertyValue == "visible" + } + + default: + break + } + } + } + + private class func interpretExpression(propertyName: String, expression: String) -> NSExpression? { + let isColor = propertyName.contains("color"); + let isOffset = propertyName.contains("offset"); + let isTranslate = propertyName.contains("translate"); + + do { + let json = try JSONSerialization.jsonObject(with: expression.data(using: .utf8)!, options: .fragmentsAllowed) + + // Check if JSON contains NSNull - this would create an invalid NSExpression + if json is NSNull { + return nil + } + + // this is required because NSExpression.init(mglJSONObject: json) fails to create + // a proper Expression if the data of is a hexString + if isColor { + if let color = json as? String { + return NSExpression(forConstantValue: UIColor(hexString: color)) + } + } + + if let offset = json as? [Any]{ + // checks on the value of property that are literal expressions + if offset.count == 2 && offset.first is String && offset.first as? String == "literal" { + if let vector = offset.last as? [Any]{ + if(vector.count == 2) { + if isOffset || isTranslate { + // this is required because NSExpression.init(mglJSONObject: json) fails to create + // a proper Expression if the data of a literal is an array destined for a CGVector + if let x = vector.first as? Double, let y = vector.last as? Double { + return NSExpression(forConstantValue: NSValue(cgVector: CGVector(dx: x, dy: y))) + } + } + return NSExpression.init(mglJSONObject: json) + } + } + // checks on the value of properties that are arrays + } else if offset.count == 2, let x = offset.first as? Double, let y = offset.last as? Double { + if isOffset || isTranslate { + // this is required because NSExpression.init(mglJSONObject: json) fails to create + // a proper Expression if the data of an array is destined for a CGVector + return NSExpression(forConstantValue: NSValue(cgVector: CGVector(dx: x, dy: y))) + } + // this is required because NSExpression.init(mglJSONObject: json) fails to create + // a proper Expression if the data is an array of double + return NSExpression(forConstantValue: [NSNumber(value: x), NSNumber(value: y)]) + } else { + // Handle arrays with any number of elements (e.g., dash arrays with 3+ elements) + // Convert to array of NSNumbers for proper expression creation + let numbers = offset.compactMap { $0 as? Double }.map { NSNumber(value: $0) } + if numbers.count == offset.count { + return NSExpression(forConstantValue: numbers) + } + } + } + + return NSExpression.init(mglJSONObject: json) + } catch { + } + return nil + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MGLMapView+setLanguage.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MGLMapView+setLanguage.swift new file mode 100644 index 00000000..a19be863 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MGLMapView+setLanguage.swift @@ -0,0 +1,55 @@ +// +// MLNMapView+setLanguage.swift +// maplibre_gl +// +// Created by Julian Bissekkou on 09.08.23. +// + +import Foundation +import MapLibre + +extension MLNMapView { + func setMapLanguage(_ language: String) { + guard let style = style else { return } + + let layers = style.layers + + for layer in layers { + if let symbolLayer = layer as? MLNSymbolStyleLayer { + if symbolLayer.text == nil { + continue + } + + // We could skip the current iteration, whenever there is not current language. + if !symbolLayer.text.description.containsLanguage() { + continue + } + + let properties = ["text-field": "[\"coalesce\",[\"get\",\"name:\(language)\"],[\"get\",\"name:latin\"],[\"get\",\"name\"]]"] + + LayerPropertyConverter.addSymbolProperties( + symbolLayer: symbolLayer, + properties: properties + ) + } + } + } +} + + +private extension String { + func containsLanguage() -> Bool { + do { + let regex = try NSRegularExpression(pattern: "(name:[a-z]+)") + let range = NSRange(location: 0, length: self.utf16.count) + + if let _ = regex.firstMatch(in: self, options: [], range: range) { + return true + } else { + return false + } + } catch { + return false + } + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreCustomHeaders.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreCustomHeaders.swift new file mode 100644 index 00000000..78b72905 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreCustomHeaders.swift @@ -0,0 +1,39 @@ +import Foundation +import MapLibre + +public class MapLibreCustomHeaders { + private static var customHeaders: [String: String] = [:] + private static var filterPatterns: [String] = [] + + public static func setCustomHeaders(_ headers: [String: String], filter: [String]) { + customHeaders = headers + filterPatterns = filter + + let sessionConfig = URLSessionConfiguration.default + sessionConfig.httpAdditionalHeaders = headers + MLNNetworkConfiguration.sharedManager.sessionConfiguration = sessionConfig + } + + public static func getCustomHeaders() -> [String: String] { + return customHeaders + } + + public static func getFilterPatterns() -> [String] { + return filterPatterns + } + + public static func shouldApplyHeaders(to url: String) -> Bool { + if filterPatterns.isEmpty { + return true + } + + for pattern in filterPatterns { + if url.range(of: pattern, options: .regularExpression) != nil { + return true + } + } + + return false + } +} + diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapController.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapController.swift new file mode 100644 index 00000000..d49a4af4 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapController.swift @@ -0,0 +1,2131 @@ +import Flutter +import MapLibre + +class MapLibreMapController: NSObject, FlutterPlatformView, MLNMapViewDelegate, MapLibreMapOptionsSink, + UIGestureRecognizerDelegate +{ + private var registrar: FlutterPluginRegistrar + private var channel: FlutterMethodChannel? + + private var mapView: MLNMapView + private var isMapReady = false + private var dragEnabled = true + private var isFirstStyleLoad = true + private var onStyleLoadedCalled = false + private var mapReadyResult: FlutterResult? + private var previousDragCoordinate: CLLocationCoordinate2D? + private var originDragCoordinate: CLLocationCoordinate2D? + private var dragFeature: MLNFeature? + + private var initialTilt: CGFloat? + private var trackCameraPosition = false + private var myLocationEnabled = false + private var scrollingEnabled = true + private var isAdjustingCameraProgrammatically = false + + private var interactiveFeatureLayerIds = Set() + private var addedShapesByLayer = [String: MLNShape]() + + func view() -> UIView { + return mapView + } + + private var styleIsReady: Bool { + return onStyleLoadedCalled && mapView.style != nil + } + + private static func createMapView( + args: Any?, + frame: CGRect, + registrar: FlutterPluginRegistrar + ) -> MLNMapView { + if let args = args as? [String: Any], + let styleString = args["styleString"] as? String + { + if Self.styleStringIsJSON(styleString) { + return MLNMapView(frame: frame, styleJSON: styleString) + } + + if let url = Self.styleStringAsURL( + styleString, + registrar: registrar + ) { + return MLNMapView(frame: frame, styleURL: url) + } + } + + // Fallback to default if neither JSON nor valid URL + NSLog( + """ + Warning: MapLibreMapController - Initializing map view with \ + default style. This capability will be removed in a future release. + """ + ) + // https://github.com/maplibre/maplibre-native/issues/709 + return MLNMapView(frame: frame) + } + + init( + withFrame frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any?, + registrar: FlutterPluginRegistrar + ) { + mapView = Self.createMapView( + args: args, + frame: frame, + registrar: registrar + ) + + mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + self.registrar = registrar + + super.init() + + channel = FlutterMethodChannel( + name: "plugins.flutter.io/maplibre_gl_\(viewId)", + binaryMessenger: registrar.messenger() + ) + channel! + .setMethodCallHandler { [weak self] in self?.onMethodCall(methodCall: $0, result: $1) } + + mapView.delegate = self + + let singleTap = UITapGestureRecognizer( + target: self, + action: #selector(handleMapTap(sender:)) + ) + for recognizer in mapView.gestureRecognizers! + where (recognizer as? UITapGestureRecognizer)?.numberOfTapsRequired == 2 { + singleTap.require(toFail: recognizer) + } + mapView.addGestureRecognizer(singleTap) + + let longPress = UILongPressGestureRecognizer( + target: self, + action: #selector(handleMapLongPress(sender:)) + ) + var longPressRecognizerAdded = false + + if let args = args as? [String: Any] { + + Convert.interpretMapLibreMapOptions(options: args["options"], delegate: self) + if let initialCameraPosition = args["initialCameraPosition"] as? [String: Any], + let camera = MLNMapCamera.fromDict(initialCameraPosition, mapView: mapView), + let zoom = initialCameraPosition["zoom"] as? Double + { + mapView.setCenter( + camera.centerCoordinate, + zoomLevel: zoom, + direction: camera.heading, + animated: false + ) + initialTilt = camera.pitch + } + // if let onAttributionClickOverride = args["onAttributionClickOverride"] as? Bool { + // if onAttributionClickOverride { + // setupAttribution(mapView) + // } + // } + + if let enabled = args["dragEnabled"] as? Bool { + dragEnabled = enabled + } + + if let iosLongClickDurationMilliseconds = args["iosLongClickDurationMilliseconds"] as? Int { + longPress.minimumPressDuration = TimeInterval(iosLongClickDurationMilliseconds) / 1000 + mapView.addGestureRecognizer(longPress) + longPressRecognizerAdded = true + } + } + if dragEnabled { + let pan = UIPanGestureRecognizer( + target: self, + action: #selector(handleMapPan(sender:)) + ) + pan.delegate = self + mapView.addGestureRecognizer(pan) + } + + if(!longPressRecognizerAdded) { + mapView.addGestureRecognizer(longPress) + longPressRecognizerAdded = true + } + } + + func gestureRecognizer( + _: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer + ) -> Bool { + return true + } + + func onMethodCall(methodCall: FlutterMethodCall, result: @escaping FlutterResult) { + switch methodCall.method { + case "map#waitForMap": + if isMapReady { + result(nil) + // only call map#onStyleLoaded here if isMapReady has happend and isFirstStyleLoad is true + if isFirstStyleLoad { + isFirstStyleLoad = false + if let channel = channel { + onStyleLoadedCalled = true + channel.invokeMethod("map#onStyleLoaded", arguments: nil) + } + } + } else { + mapReadyResult = result + } + case "map#update": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + Convert.interpretMapLibreMapOptions(options: arguments["options"], delegate: self) + if let camera = getCamera() { + result(camera.toDict(mapView: mapView)) + } else { + result(nil) + } + case "map#invalidateAmbientCache": + MLNOfflineStorage.shared.invalidateAmbientCache { + error in + if let error = error { + result(error) + } else { + result(nil) + } + } + case "map#clearAmbientCache": + MLNOfflineStorage.shared.clearAmbientCache { + error in + if let error = error { + result(error) + } else { + result(nil) + } + } + case "map#updateMyLocationTrackingMode": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + if let myLocationTrackingMode = arguments["mode"] as? UInt, + let trackingMode = MLNUserTrackingMode(rawValue: myLocationTrackingMode) + { + setMyLocationTrackingMode(myLocationTrackingMode: trackingMode) + } + result(nil) + case "map#matchMapLanguageWithDeviceDefault": + if let langStr = Locale.current.languageCode { + setMapLanguage(language: langStr) + } + + result(nil) + case "map#updateContentInsets": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + + if let bounds = arguments["bounds"] as? [String: Any], + let top = bounds["top"] as? CGFloat, + let left = bounds["left"] as? CGFloat, + let bottom = bounds["bottom"] as? CGFloat, + let right = bounds["right"] as? CGFloat, + let animated = arguments["animated"] as? Bool + { + mapView.setContentInset( + UIEdgeInsets(top: top, left: left, bottom: bottom, right: right), + animated: animated + ) { + result(nil) + } + } else { + result(nil) + } + case "locationComponent#getLastLocation": + var reply = [String: NSObject]() + if let loc = mapView.userLocation?.location?.coordinate { + reply["latitude"] = loc.latitude as NSObject + reply["longitude"] = loc.longitude as NSObject + result(reply) + } else { + result(nil) + } + case "map#setMapLanguage": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + if let localIdentifier = arguments["language"] as? String { + setMapLanguage(language: localIdentifier) + } + result(nil) + case "map#queryRenderedFeatures": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + var styleLayerIdentifiers: Set? + if let layerIds = arguments["layerIds"] as? [String], !layerIds.isEmpty { + styleLayerIdentifiers = Set(layerIds) + } + var filterExpression: NSPredicate? + if let filter = arguments["filter"] as? [Any] { + filterExpression = NSPredicate(mglJSONObject: filter) + } + var reply = [String: NSObject]() + var features: [MLNFeature] = [] + if let x = arguments["x"] as? Double, let y = arguments["y"] as? Double { + features = mapView.visibleFeatures( + at: CGPoint(x: x, y: y), + styleLayerIdentifiers: styleLayerIdentifiers, + predicate: filterExpression + ) + } + if let top = arguments["top"] as? Double, + let bottom = arguments["bottom"] as? Double, + let left = arguments["left"] as? Double, + let right = arguments["right"] as? Double + { + var width = right - left + var height = bottom - top + features = mapView.visibleFeatures(in: CGRect(x: left, y: top, width: width, height: height), styleLayerIdentifiers: styleLayerIdentifiers, predicate: filterExpression) + } + var featuresJson = [String]() + for feature in features { + let dictionary = feature.geoJSONDictionary() + if let theJSONData = try? JSONSerialization.data( + withJSONObject: dictionary, + options: [] + ), + let theJSONText = String(data: theJSONData, encoding: .utf8) + { + featuresJson.append(theJSONText) + } + } + reply["features"] = featuresJson as NSObject + result(reply) + case "map#setTelemetryEnabled": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + let telemetryEnabled = arguments["enabled"] as? Bool + UserDefaults.standard.set(telemetryEnabled, forKey: "MLNMapboxMetricsEnabled") + result(nil) + case "map#getTelemetryEnabled": + let telemetryEnabled = UserDefaults.standard.bool(forKey: "MLNMapboxMetricsEnabled") + result(telemetryEnabled) + case "map#setMaximumFps": + result(nil) + case "map#forceOnlineMode": + // Force online mode by ensuring network requests are enabled + // In MapLibre GL iOS, this is typically handled by the style and data sources + result(nil) + case "camera#ease": + guard let arguments = methodCall.arguments as? [String: Any] else { + result(false) + return + } + guard let cameraUpdate = arguments["cameraUpdate"] as? [Any] else { + result(false) + return + } + guard let camera = Convert.parseCameraUpdate(cameraUpdate: cameraUpdate, mapView: mapView) else { + result(false) + return + } + // MESHMAPPER GUARD: refuse camera ops while the map view has no usable + // viewport. MapLibre's transform unprojects against the viewport and + // throws an uncaught C++ std::domain_error (SIGABRT, uncatchable from + // Dart) when it is degenerate/zero-sized. Complete the Future so the + // Dart-side await still returns. + if mapView.bounds.width < 1 || mapView.bounds.height < 1 { + result(false) + return + } + + let completion = { + result(true) + } + + if let duration = arguments["duration"] as? Double, duration > 0 { + let interval: TimeInterval = duration / 1000.0 + mapView.setCamera(camera, withDuration: interval, animationTimingFunction: CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut), completionHandler: completion) + } else { + mapView.setCamera(camera, animated: true) + completion() + } + case "map#queryCameraPosition": + if let camera = getCamera() { + result(camera.toDict(mapView: mapView)) + } else { + result(nil) + } + case "map#editGeoJsonSource": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let srcId = arguments["id"] as? String else { return } + guard let srcData = arguments["data"] as? String else { return } + guard let style = self.mapView.style else { return } + + var ret: Bool = false + var reply: [String: Bool] = [:] + if let data = srcData.data(using: String.Encoding.utf8) { + let src = style.source(withIdentifier: srcId) + if src != nil && src is MLNShapeSource { + let geojsonSrc = src as! MLNShapeSource + let geojsonData = try? MLNShape(data: data, encoding: String.Encoding.utf8.rawValue) + if geojsonData != nil { + geojsonSrc.shape = geojsonData + ret = true + } + } + } + + reply["result"] = ret + result(reply) + case "map#editGeoJsonUrl": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let srcId = arguments["id"] as? String else { return } + guard let srcUrl = arguments["url"] as? String else { return } + guard let style = self.mapView.style else { return } + + var ret: Bool = false + var reply: [String: Bool] = [:] + let src = style.source(withIdentifier: srcId) + if src != nil && src is MLNShapeSource { + let geojsonSrc = src as! MLNShapeSource + let geojsonUrl = URL(string: srcUrl) + if geojsonUrl != nil { + geojsonSrc.url = geojsonUrl + ret = true + } + } + + reply["result"] = ret + result(reply) + case "map#setLayerFilter": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let layerId = arguments["id"] as? String else { return } + guard let layerFilter = arguments["filter"] as? String else { return } + guard let style = self.mapView.style else { return } + + var ret: Bool = false + var reply: [String: Bool] = [:] + let layer = style.layer(withIdentifier: layerId) + if layer != nil { + do { + if let data = layerFilter.data(using: .utf8) { + let jsonFilter = try JSONSerialization.jsonObject(with: data, options: []) + let predicate = NSPredicate(mglJSONObject: jsonFilter) + if let layer = layer as? MLNVectorStyleLayer { + layer.predicate = predicate + ret = true + } + } + } catch { + print("Error parsing filter: \(error.localizedDescription)") + } + } + + reply["result"] = ret + result(reply) + case "map#getStyle": + var reply: [String: Bool] = [:] + reply["result"] = false + result(reply) + case "map#setCustomHeaders": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let headers = arguments["headers"] as? [String:String] else { return } + guard let filter = arguments["filter"] as? [String] else { return } + MapLibreCustomHeaders.setCustomHeaders(headers, filter: filter) + result(nil) + case "map#getCustomHeaders": + result(MapLibreCustomHeaders.getCustomHeaders()) + case "map#getVisibleRegion": + var reply = [String: NSObject]() + let visibleRegion = mapView.visibleCoordinateBounds + reply["sw"] = [visibleRegion.sw.latitude, visibleRegion.sw.longitude] as NSObject + reply["ne"] = [visibleRegion.ne.latitude, visibleRegion.ne.longitude] as NSObject + result(reply) + case "map#toScreenLocation": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let latitude = arguments["latitude"] as? Double else { return } + guard let longitude = arguments["longitude"] as? Double else { return } + let latlng = CLLocationCoordinate2DMake(latitude, longitude) + let returnVal = mapView.convert(latlng, toPointTo: mapView) + var reply = [String: NSObject]() + reply["x"] = returnVal.x as NSObject + reply["y"] = returnVal.y as NSObject + result(reply) + case "map#toScreenLocationBatch": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let data = arguments["coordinates"] as? FlutterStandardTypedData else { return } + let latLngs = data.data.withUnsafeBytes { + Array( + UnsafeBufferPointer( + start: $0.baseAddress!.assumingMemoryBound(to: Double.self), + count: Int(data.elementCount) + ) + ) + } + var reply: [Double] = Array(repeating: 0.0, count: latLngs.count) + for i in stride(from: 0, to: latLngs.count, by: 2) { + let coordinate = CLLocationCoordinate2DMake(latLngs[i], latLngs[i + 1]) + let returnVal = mapView.convert(coordinate, toPointTo: mapView) + reply[i] = Double(returnVal.x) + reply[i + 1] = Double(returnVal.y) + } + result(FlutterStandardTypedData( + float64: Data(bytes: &reply, count: reply.count * 8) + )) + case "map#getMetersPerPixelAtLatitude": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + var reply = [String: NSObject]() + guard let latitude = arguments["latitude"] as? Double else { return } + let returnVal = mapView.metersPerPoint(atLatitude: latitude) + reply["metersperpixel"] = returnVal as NSObject + result(reply) + case "map#toLatLng": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let x = arguments["x"] as? Double else { return } + guard let y = arguments["y"] as? Double else { return } + let screenPoint = CGPoint(x: x, y: y) + let coordinates: CLLocationCoordinate2D = mapView.convert( + screenPoint, + toCoordinateFrom: mapView + ) + var reply = [String: NSObject]() + reply["latitude"] = coordinates.latitude as NSObject + reply["longitude"] = coordinates.longitude as NSObject + result(reply) + case "camera#move": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let cameraUpdate = arguments["cameraUpdate"] as? [Any] else { return } + + // MESHMAPPER GUARD: skip camera ops while the map view has no usable + // viewport (see camera#ease) — prevents the uncatchable SIGABRT. + if mapView.bounds.width < 1 || mapView.bounds.height < 1 { + result(nil) + return + } + + if let camera = Convert.parseCameraUpdate(cameraUpdate: cameraUpdate, mapView: mapView) { + mapView.setCamera(camera, animated: false) + } + result(nil) + case "camera#animate": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let cameraUpdate = arguments["cameraUpdate"] as? [Any] else { return } + guard let camera = Convert.parseCameraUpdate(cameraUpdate: cameraUpdate, mapView: mapView) else { return } + + // MESHMAPPER GUARD: skip camera ops while the map view has no usable + // viewport (see camera#ease) — prevents the uncatchable SIGABRT. + if mapView.bounds.width < 1 || mapView.bounds.height < 1 { + result(nil) + return + } + + let completion = { + result(nil) + } + + if let duration = arguments["duration"] as? TimeInterval { + if let padding = Convert.parseLatLngBoundsPadding(cameraUpdate) { + mapView.fly(to: camera, edgePadding: padding, withDuration: duration / 1000, completionHandler: completion) + } else { + mapView.fly(to: camera, withDuration: duration / 1000, completionHandler: completion) + } + } else { + mapView.setCamera(camera, animated: true) + completion() + } + case "symbolLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + guard let enableInteraction = arguments["enableInteraction"] as? Bool else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let sourceLayer = arguments["sourceLayer"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + let filter = arguments["filter"] as? String + + let addResult = addSymbolLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + sourceLayerIdentifier: sourceLayer, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + properties: properties + ) + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "lineLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + guard let enableInteraction = arguments["enableInteraction"] as? Bool else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let sourceLayer = arguments["sourceLayer"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + let filter = arguments["filter"] as? String + + let addResult = addLineLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + sourceLayerIdentifier: sourceLayer, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + properties: properties + ) + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "layer#setProperties": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + + guard let layer = mapView.style?.layer(withIdentifier: layerId) else { + result(FlutterError( + code: "LAYER_NOT_FOUND_ERROR", + message: "Layer " + layerId + "not found", + details: "" + )) + return + } + + //switch depending on the runtime type of layer + switch layer { + case let lineLayer as MLNLineStyleLayer: + LayerPropertyConverter.addLineProperties(lineLayer: lineLayer, properties: properties) + case let fillLayer as MLNFillStyleLayer: + LayerPropertyConverter.addFillProperties(fillLayer: fillLayer, properties: properties) + case let circleLayer as MLNCircleStyleLayer: + LayerPropertyConverter.addCircleProperties(circleLayer: circleLayer, properties: properties) + case let symbolLayer as MLNSymbolStyleLayer: + LayerPropertyConverter.addSymbolProperties(symbolLayer: symbolLayer, properties: properties) + case let rasterLayer as MLNRasterStyleLayer: + LayerPropertyConverter.addRasterProperties(rasterLayer: rasterLayer, properties: properties) + case let hillshadeLayer as MLNHillshadeStyleLayer: + LayerPropertyConverter.addHillshadeProperties(hillshadeLayer: hillshadeLayer, properties: properties) + default: + result(FlutterError( + code: "UNSUPPORTED_LAYER_TYPE", + message: "Layer type not supported", + details: "" + )) + return + } + + result(nil) + + case "fillLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + guard let enableInteraction = arguments["enableInteraction"] as? Bool else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let sourceLayer = arguments["sourceLayer"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + let filter = arguments["filter"] as? String + + let addResult = addFillLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + sourceLayerIdentifier: sourceLayer, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + properties: properties + ) + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "fillExtrusionLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + guard let enableInteraction = arguments["enableInteraction"] as? Bool else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let sourceLayer = arguments["sourceLayer"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + let filter = arguments["filter"] as? String + + let addResult = addFillExtrusionLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + sourceLayerIdentifier: sourceLayer, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + properties: properties + ) + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "circleLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + guard let enableInteraction = arguments["enableInteraction"] as? Bool else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let sourceLayer = arguments["sourceLayer"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + let filter = arguments["filter"] as? String + + let addResult = addCircleLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + sourceLayerIdentifier: sourceLayer, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + properties: properties + ) + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "hillshadeLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + + let addResult = addHillshadeLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + properties: properties + ) + + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "heatmapLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + let addResult = addHeatmapLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + properties: properties + ) + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "rasterLayer#add": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: String] else { return } + let belowLayerId = arguments["belowLayerId"] as? String + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + let addResult = addRasterLayer( + sourceId: sourceId, + layerId: layerId, + belowLayerId: belowLayerId, + minimumZoomLevel: minzoom, + maximumZoomLevel: maxzoom, + properties: properties + ) + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "style#addImage": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let name = arguments["name"] as? String else { return } + // guard let length = arguments["length"] as? NSNumber else { return } + guard let bytes = arguments["bytes"] as? FlutterStandardTypedData else { return } + guard let sdf = arguments["sdf"] as? Bool else { return } + guard let data = bytes.data as? Data else { return } + guard let image = UIImage(data: data, scale: UIScreen.main.scale) else { return } + if sdf { + mapView.style?.setImage(image.withRenderingMode(.alwaysTemplate), forName: name) + } else { + mapView.style?.setImage(image, forName: name) + } + result(nil) + + case "style#addImageSource": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let imageSourceId = arguments["imageSourceId"] as? String else { return } + guard let bytes = arguments["bytes"] as? FlutterStandardTypedData else { return } + guard let data = bytes.data as? Data else { return } + guard let image = UIImage(data: data) else { return } + + guard let coordinates = arguments["coordinates"] as? [[Double]] else { return } + let quad = MLNCoordinateQuad( + topLeft: CLLocationCoordinate2D( + latitude: coordinates[0][0], + longitude: coordinates[0][1] + ), + bottomLeft: CLLocationCoordinate2D( + latitude: coordinates[3][0], + longitude: coordinates[3][1] + ), + bottomRight: CLLocationCoordinate2D( + latitude: coordinates[2][0], + longitude: coordinates[2][1] + ), + topRight: CLLocationCoordinate2D( + latitude: coordinates[1][0], + longitude: coordinates[1][1] + ) + ) + + // Check for duplicateSource error + if mapView.style?.source(withIdentifier: imageSourceId) != nil { + result(FlutterError( + code: "duplicateSource", + message: "Source with imageSourceId \(imageSourceId) already exists", + details: "Can't add duplicate source with imageSourceId: \(imageSourceId)" + )) + return + } + + let source = MLNImageSource( + identifier: imageSourceId, + coordinateQuad: quad, + image: image + ) + mapView.style?.addSource(source) + + result(nil) + case "style#updateImageSource": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let imageSourceId = arguments["imageSourceId"] as? String else { return } + guard let imageSource = mapView.style? + .source(withIdentifier: imageSourceId) as? MLNImageSource else { return } + let bytes = arguments["bytes"] as? FlutterStandardTypedData + if bytes != nil { + guard let data = bytes!.data as? Data else { return } + guard let image = UIImage(data: data) else { return } + imageSource.image = image + } + let coordinates = arguments["coordinates"] as? [[Double]] + if coordinates != nil { + let quad = MLNCoordinateQuad( + topLeft: CLLocationCoordinate2D( + latitude: coordinates![0][0], + longitude: coordinates![0][1] + ), + bottomLeft: CLLocationCoordinate2D( + latitude: coordinates![3][0], + longitude: coordinates![3][1] + ), + bottomRight: CLLocationCoordinate2D( + latitude: coordinates![2][0], + longitude: coordinates![2][1] + ), + topRight: CLLocationCoordinate2D( + latitude: coordinates![1][0], + longitude: coordinates![1][1] + ) + ) + imageSource.coordinates = quad + } + result(nil) + case "style#removeSource": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let source = mapView.style?.source(withIdentifier: sourceId) else { + result(nil) + return + } + mapView.style?.removeSource(source) + result(nil) + case "style#addLayer": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let imageLayerId = arguments["imageLayerId"] as? String else { return } + guard let imageSourceId = arguments["imageSourceId"] as? String else { return } + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + + // Check for duplicateLayer error + if (mapView.style?.layer(withIdentifier: imageLayerId)) != nil { + result(FlutterError( + code: "duplicateLayer", + message: "Layer already exists", + details: "Can't add duplicate layer with imageLayerId: \(imageLayerId)" + )) + return + } + // Check for noSuchSource error + guard let source = mapView.style?.source(withIdentifier: imageSourceId) else { + result(FlutterError( + code: "noSuchSource", + message: "No source found with imageSourceId \(imageSourceId)", + details: "Can't add add layer for imageSourceId \(imageLayerId), as the source does not exist." + )) + return + } + + let layer = MLNRasterStyleLayer(identifier: imageLayerId, source: source) + + if let minzoom = minzoom { + layer.minimumZoomLevel = Float(minzoom) + } + + if let maxzoom = maxzoom { + layer.maximumZoomLevel = Float(maxzoom) + } + + mapView.style?.addLayer(layer) + case "style#addLayerBelow": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let imageLayerId = arguments["imageLayerId"] as? String else { return } + guard let imageSourceId = arguments["imageSourceId"] as? String else { return } + guard let belowLayerId = arguments["belowLayerId"] as? String else { return } + let minzoom = arguments["minzoom"] as? Double + let maxzoom = arguments["maxzoom"] as? Double + + // Check for duplicateLayer error + if (mapView.style?.layer(withIdentifier: imageLayerId)) != nil { + result(FlutterError( + code: "duplicateLayer", + message: "Layer already exists", + details: "Can't add duplicate layer with imageLayerId: \(imageLayerId)" + )) + return + } + // Check for noSuchSource error + guard let source = mapView.style?.source(withIdentifier: imageSourceId) else { + result(FlutterError( + code: "noSuchSource", + message: "No source found with imageSourceId \(imageSourceId)", + details: "Can't add add layer for imageSourceId \(imageLayerId), as the source does not exist." + )) + return + } + // Check for noSuchLayer error + guard let belowLayer = mapView.style?.layer(withIdentifier: belowLayerId) else { + result(FlutterError( + code: "noSuchLayer", + message: "No layer found with layerId \(belowLayerId)", + details: "Can't insert layer below layer with id \(belowLayerId), as no such layer exists." + )) + return + } + + let layer = MLNRasterStyleLayer(identifier: imageLayerId, source: source) + + if let minzoom = minzoom { + layer.minimumZoomLevel = Float(minzoom) + } + + if let maxzoom = maxzoom { + layer.maximumZoomLevel = Float(maxzoom) + } + mapView.style?.insertLayer(layer, below: belowLayer) + result(nil) + + case "style#removeLayer": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let layer = mapView.style?.layer(withIdentifier: layerId) else { + result(MethodCallError.layerNotFound( + layerId: layerId + ).flutterError) + return + } + interactiveFeatureLayerIds.remove(layerId) + mapView.style?.removeLayer(layer) + result(nil) + + case "map#setCameraBounds": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let west = arguments["west"] as? Double else { return } + guard let north = arguments["north"] as? Double else { return } + guard let south = arguments["south"] as? Double else { return } + guard let east = arguments["east"] as? Double else { return } + guard let padding = arguments["padding"] as? CGFloat else { return } + + let southwest = CLLocationCoordinate2D(latitude: south, longitude: west) + let northeast = CLLocationCoordinate2D(latitude: north, longitude: east) + let bounds = MLNCoordinateBounds(sw: southwest, ne: northeast) + mapView.setVisibleCoordinateBounds(bounds, edgePadding: UIEdgeInsets(top: padding, + left: padding, bottom: padding, right: padding) , animated: true) + result(nil) + + case "style#setFilter": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let filter = arguments["filter"] as? String else { return } + guard let layer = mapView.style?.layer(withIdentifier: layerId) else { + result(MethodCallError.layerNotFound( + layerId: layerId + ).flutterError) + return + } + switch setFilter(layer, filter) { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "source#addGeoJson": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let geojson = arguments["geojson"] as? String else { return } + let addResult = addSourceGeojson(sourceId: sourceId, geojson: geojson) + + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "style#addSource": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let properties = arguments["properties"] as? [String: Any] else { return } + let addResult = addSource(sourceId: sourceId, properties: properties) + + switch addResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "source#setGeoJson": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let geojson = arguments["geojson"] as? String else { return } + let setResult = setSource(sourceId: sourceId, geojson: geojson) + + switch setResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "source#setFeature": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + guard let geojson = arguments["geojsonFeature"] as? String else { return } + let setResult = setFeature(sourceId: sourceId, geojsonFeature: geojson) + + switch setResult { + case .success: result(nil) + case let .failure(error): result(error.flutterError) + } + + case "layer#setVisibility": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let layerId = arguments["layerId"] as? String else { return } + guard let visible = arguments["visible"] as? Bool else { return } + guard let layer = mapView.style?.layer(withIdentifier: layerId) else { + result(MethodCallError.layerNotFound( + layerId: layerId + ).flutterError) + return + } + layer.isVisible = visible + result(nil) + + case "map#querySourceFeatures": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let sourceId = arguments["sourceId"] as? String else { return } + + var sourceLayerId = Set() + if let layerId = arguments["sourceLayerId"] as? String { + sourceLayerId.insert(layerId) + } + var filterExpression: NSPredicate? + if let filter = arguments["filter"] as? [Any] { + filterExpression = NSPredicate(mglJSONObject: filter) + } + + var reply = [String: NSObject]() + var features: [MLNFeature] = [] + + guard let style = mapView.style else { return } + if let source = style.source(withIdentifier: sourceId) { + if let vectorSource = source as? MLNVectorTileSource { + features = vectorSource.features(sourceLayerIdentifiers: sourceLayerId, predicate: filterExpression) + } else if let shapeSource = source as? MLNShapeSource { + features = shapeSource.features(matching: filterExpression) + } + } + + var featuresJson = [String]() + for feature in features { + let dictionary = feature.geoJSONDictionary() + if let theJSONData = try? JSONSerialization.data( + withJSONObject: dictionary, + options: [] + ), + let theJSONText = String(data: theJSONData, encoding: .utf8) + { + featuresJson.append(theJSONText) + } + } + reply["features"] = featuresJson as NSObject + result(reply) + + case "style#getLayerIds": + var layerIds = [String]() + + guard let style = mapView.style else { return } + + style.layers.forEach { layer in layerIds.append(layer.identifier) } + + var reply = [String: NSObject]() + reply["layers"] = layerIds as NSObject + result(reply) + + case "style#getSourceIds": + var sourceIds = [String]() + + guard let style = mapView.style else { return } + + style.sources.forEach { source in sourceIds.append(source.identifier) } + + var reply = [String: NSObject]() + reply["sources"] = sourceIds as NSObject + result(reply) + + case "style#getFilter": + guard let arguments = methodCall.arguments as? [String: Any] else { return } + guard let layerId = arguments["layerId"] as? String else { return } + + guard let style = mapView.style else { return } + guard let layer = style.layer(withIdentifier: layerId) else { return } + + var currentLayerFilter : String = "" + if let vectorLayer = layer as? MLNVectorStyleLayer { + if let layerFilter = vectorLayer.predicate { + + let jsonExpression = layerFilter.mgl_jsonExpressionObject + if let data = try? JSONSerialization.data(withJSONObject: jsonExpression, options: []) { + currentLayerFilter = String(data: data, encoding: String.Encoding.utf8) ?? "" + } + } + } else { + result(MethodCallError.invalidLayerType( + details: "Layer '\(layer.identifier)' does not support filtering." + ).flutterError) + return; + } + + var reply = [String: NSObject]() + reply["filter"] = currentLayerFilter as NSObject + result(reply) + + case "style#setStyle": + if let arguments = methodCall.arguments as? [String: Any] { + if let style = arguments["style"] as? String { + setStyleString(styleString: style) + result(nil) + } else { + // Error for missing style key in argument + result( + FlutterError( + code: "invalidStyleString", + message: "Missing style key in arguments", + details: nil + ) + ) + } + } else { + // Error for invalid arguments type + result( + FlutterError( + code: "invalidArgumentsType", + message: "Arguments not of type [String: Any]", + details: nil + ) + ) + } + default: + result(FlutterMethodNotImplemented) + } + } + + private func loadIconImage(name: String) -> UIImage? { + // Build up the full path of the asset. + // First find the last '/' ans split the image name in the asset directory and the image file name. + if let range = name.range(of: "/", options: [.backwards]) { + let directory = String(name[.. MLNMapCamera? { + return trackCameraPosition ? mapView.camera : nil + } + + private func setMapLanguage(language: String) { + self.mapView.setMapLanguage(language) + } + + /* + * Scan layers from top to bottom and return the first matching feature + */ + private func firstFeatureOnLayers(at: CGPoint) -> (feature: MLNFeature?, layerId: String?) { + guard let style = mapView.style else { + NSLog("MapLibreMapController - Map style is nil") + return (nil, nil) + } + + guard styleIsReady else { + NSLog("MapLibreMapController - Map style is not ready yet") + return (nil, nil) + } + + // get layers in order (interactiveFeatureLayerIds is unordered) + let clickableLayers = style.layers.filter { layer in + interactiveFeatureLayerIds.contains(layer.identifier) + } + + for layer in clickableLayers.reversed() { + let features = mapView.visibleFeatures( + at: at, + styleLayerIdentifiers: [layer.identifier] + ) + if let feature = features.first { + return (feature, layer.identifier) + } + } + return (nil, nil) + } + + /* + * UITapGestureRecognizer + * On tap invoke the map#onMapClick callback. + */ + @IBAction func handleMapTap(sender: UITapGestureRecognizer) { + // Get the CGPoint where the user tapped. + let point = sender.location(in: mapView) + let coordinate = mapView.convert(point, toCoordinateFrom: mapView) + + let result = firstFeatureOnLayers(at: point) + if let feature = result.feature { + channel?.invokeMethod("feature#onTap", arguments: [ + "id": feature.identifier, + "x": point.x, + "y": point.y, + "lng": coordinate.longitude, + "lat": coordinate.latitude, + "layerId": result.layerId, + ]) + } else { + channel?.invokeMethod("map#onMapClick", arguments: [ + "x": point.x, + "y": point.y, + "lng": coordinate.longitude, + "lat": coordinate.latitude, + ]) + } + } + + fileprivate func invokeFeatureDrag( + _ point: CGPoint, + _ coordinate: CLLocationCoordinate2D, + _ eventType: String + ) { + if let feature = dragFeature, + let id = feature.identifier, + let previous = previousDragCoordinate, + let origin = originDragCoordinate + { + channel?.invokeMethod("feature#onDrag", arguments: [ + "id": id, + "x": point.x, + "y": point.y, + "originLng": origin.longitude, + "originLat": origin.latitude, + "currentLng": coordinate.longitude, + "currentLat": coordinate.latitude, + "eventType": eventType, + "deltaLng": coordinate.longitude - previous.longitude, + "deltaLat": coordinate.latitude - previous.latitude, + ]) + } + } + + @IBAction func handleMapPan(sender: UIPanGestureRecognizer) { + let began = sender.state == UIGestureRecognizer.State.began + let end = sender.state == UIGestureRecognizer.State.ended + let point = sender.location(in: mapView) + let coordinate = mapView.convert(point, toCoordinateFrom: mapView) + + if dragFeature == nil, began, sender.numberOfTouches == 1 { + let result = firstFeatureOnLayers(at: point) + if let feature = result.feature, + let draggable = feature.attribute(forKey: "draggable") as? Bool, + draggable { + sender.state = UIGestureRecognizer.State.began + dragFeature = feature + originDragCoordinate = coordinate + previousDragCoordinate = coordinate + mapView.allowsScrolling = false + let eventType = "start" + invokeFeatureDrag(point, coordinate, eventType) + for gestureRecognizer in mapView.gestureRecognizers! { + if let _ = gestureRecognizer as? UIPanGestureRecognizer { + gestureRecognizer.addTarget(self, action: #selector(handleMapPan)) + break + } + } + } + } + if end, dragFeature != nil { + mapView.allowsScrolling = true + let eventType = "end" + invokeFeatureDrag(point, coordinate, eventType) + dragFeature = nil + originDragCoordinate = nil + previousDragCoordinate = nil + } + + if !began, !end, dragFeature != nil { + let eventType = "drag" + invokeFeatureDrag(point, coordinate, eventType) + previousDragCoordinate = coordinate + } + } + + /* + * UILongPressGestureRecognizer + * After a long press invoke the map#onMapLongClick callback. + */ + @IBAction func handleMapLongPress(sender: UILongPressGestureRecognizer) { + // Fire when the long press starts + if sender.state == .began { + // Get the CGPoint where the user tapped. + let point = sender.location(in: mapView) + let coordinate = mapView.convert(point, toCoordinateFrom: mapView) + channel?.invokeMethod("map#onMapLongClick", arguments: [ + "x": point.x, + "y": point.y, + "lng": coordinate.longitude, + "lat": coordinate.latitude, + ]) + } + } + + /* /* + * Override the attribution button's click target to handle the event locally. + * Called if the application supplies an onAttributionClick handler. + */ + func setupAttribution(_ mapView: MLNMapView) { + mapView.attributionButton.removeTarget( + mapView, + action: #selector(mapView.showAttribution), + for: .touchUpInside + ) + mapView.attributionButton.addTarget( + self, + action: #selector(showAttribution), + for: UIControl.Event.touchUpInside + ) + } + + /* + * Custom click handler for the attribution button. This callback is bound when + * the application specifies an onAttributionClick handler. + */ + @objc func showAttribution() { + channel?.invokeMethod("map#onAttributionClick", arguments: []) + } */ + + /* + * MLNMapViewDelegate + */ + func mapView(_ mapView: MLNMapView, didFinishLoading _: MLNStyle) { + isMapReady = true + updateMyLocationEnabled() + + if let initialTilt = initialTilt { + let camera = mapView.camera + camera.pitch = initialTilt + mapView.setCamera(camera, animated: false) + } + + addedShapesByLayer.removeAll() + interactiveFeatureLayerIds.removeAll() + + mapReadyResult?(nil) + + // On first launch we only call map#onStyleLoaded if map#waitForMap has already been called + if !isFirstStyleLoad || mapReadyResult != nil { + isFirstStyleLoad = false + + if let channel = channel { + onStyleLoadedCalled = true + channel.invokeMethod("map#onStyleLoaded", arguments: nil) + } + } + } + + // handle missing images + func mapView(_: MLNMapView, didFailToLoadImage name: String) -> UIImage? { + return loadIconImage(name: name) + } + + func mapView(_: MLNMapView, didUpdate userLocation: MLNUserLocation?) { + if let channel = channel, let userLocation = userLocation, + let location = userLocation.location + { + channel.invokeMethod("map#onUserLocationUpdated", arguments: [ + "userLocation": location.toDict(), + "heading": userLocation.heading?.toDict(), + ]) + } + } + + func mapView(_: MLNMapView, didChange mode: MLNUserTrackingMode, animated _: Bool) { + if let channel = channel { + channel.invokeMethod("map#onCameraTrackingChanged", arguments: ["mode": mode.rawValue]) + if mode == .none { + channel.invokeMethod("map#onCameraTrackingDismissed", arguments: []) + } + } + } + + private func validateBeforeLayerAdd( + sourceId: String, + layerId: String + ) -> Result<(MLNStyle, MLNSource), MethodCallError> { + guard let style = mapView.style else { + return .failure(.styleNotFound) + } + guard let source = style.source(withIdentifier: sourceId) else { + return .failure(.sourceNotFound(sourceId: sourceId)) + } + guard style.layer(withIdentifier: layerId) == nil else { + return .failure(.layerAlreadyExists(layerId: layerId)) + } + return .success((style, source)) + } + + + func addSymbolLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + sourceLayerIdentifier: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + filter: String?, + enableInteraction: Bool, + properties: [String: String] + ) -> Result { + switch validateBeforeLayerAdd(sourceId: sourceId, layerId: layerId) { + case .failure(let error): + return .failure(error) + case .success(let (style, source)): + let layer = MLNSymbolStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addSymbolProperties( + symbolLayer: layer, + properties: properties + ) + if let sourceLayerIdentifier = sourceLayerIdentifier { + layer.sourceLayerIdentifier = sourceLayerIdentifier + } + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let filter = filter { + if case let .failure(error) = setFilter(layer, filter) { + return .failure(error) + } + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + if enableInteraction { + interactiveFeatureLayerIds.insert(layerId) + } + return .success(()) + } + } + + func addLineLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + sourceLayerIdentifier: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + filter: String?, + enableInteraction: Bool, + properties: [String: String] + ) -> Result { + switch validateBeforeLayerAdd(sourceId: sourceId, layerId: layerId) { + case .failure(let error): + return .failure(error) + case .success(let (style, source)): + let layer = MLNLineStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addLineProperties(lineLayer: layer, properties: properties) + if let sourceLayerIdentifier = sourceLayerIdentifier { + layer.sourceLayerIdentifier = sourceLayerIdentifier + } + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let filter = filter { + if case let .failure(error) = setFilter(layer, filter) { + return .failure(error) + } + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + if enableInteraction { + interactiveFeatureLayerIds.insert(layerId) + } + return .success(()) + } + } + + func addFillLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + sourceLayerIdentifier: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + filter: String?, + enableInteraction: Bool, + properties: [String: String] + ) -> Result { + switch validateBeforeLayerAdd(sourceId: sourceId, layerId: layerId) { + case .failure(let error): + return .failure(error) + case .success(let (style, source)): + let layer = MLNFillStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addFillProperties(fillLayer: layer, properties: properties) + if let sourceLayerIdentifier = sourceLayerIdentifier { + layer.sourceLayerIdentifier = sourceLayerIdentifier + } + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let filter = filter { + if case let .failure(error) = setFilter(layer, filter) { + return .failure(error) + } + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + if enableInteraction { + interactiveFeatureLayerIds.insert(layerId) + } + return .success(()) + } + } + + func addFillExtrusionLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + sourceLayerIdentifier: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + filter: String?, + enableInteraction: Bool, + properties: [String: String] + ) -> Result { + switch validateBeforeLayerAdd(sourceId: sourceId, layerId: layerId) { + case .failure(let error): + return .failure(error) + case .success(let (style, source)): + let layer = MLNFillExtrusionStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addFillExtrusionProperties( + fillExtrusionLayer: layer, + properties: properties + ) + if let sourceLayerIdentifier = sourceLayerIdentifier { + layer.sourceLayerIdentifier = sourceLayerIdentifier + } + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let filter = filter { + if case let .failure(error) = setFilter(layer, filter) { + return .failure(error) + } + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + if enableInteraction { + interactiveFeatureLayerIds.insert(layerId) + } + return .success(()) + } + } + + + + func addCircleLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + sourceLayerIdentifier: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + filter: String?, + enableInteraction: Bool, + properties: [String: String] + ) -> Result { + switch validateBeforeLayerAdd(sourceId: sourceId, layerId: layerId) { + case .failure(let error): + return .failure(error) + case .success(let (style, source)): + let layer = MLNCircleStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addCircleProperties( + circleLayer: layer, + properties: properties + ) + if let sourceLayerIdentifier = sourceLayerIdentifier { + layer.sourceLayerIdentifier = sourceLayerIdentifier + } + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let filter = filter { + if case let .failure(error) = setFilter(layer, filter) { + return .failure(error) + } + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + if enableInteraction { + interactiveFeatureLayerIds.insert(layerId) + } + return .success(()) + } + } + + func setFilter(_ layer: MLNStyleLayer, _ filter: String) -> Result { + do { + let filter = try JSONSerialization.jsonObject( + with: filter.data(using: .utf8)!, + options: .fragmentsAllowed + ) + if filter is NSNull { + return .success(()) + } + let predicate = NSPredicate(mglJSONObject: filter) + if let layer = layer as? MLNVectorStyleLayer { + layer.predicate = predicate + } else { + return .failure(MethodCallError.invalidLayerType( + details: "Layer '\(layer.identifier)' does not support filtering." + )) + } + return .success(()) + } catch { + return .failure(MethodCallError.invalidExpression) + } + } + + func addHillshadeLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + properties: [String: String] + ) -> Result { + switch validateBeforeLayerAdd(sourceId: sourceId, layerId: layerId) { + case .failure(let error): + return .failure(error) + case .success(let (style, source)): + let layer = MLNHillshadeStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addHillshadeProperties( + hillshadeLayer: layer, + properties: properties + ) + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + return .success(()) + } + } + + func addHeatmapLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + properties: [String: String] + ) -> Result { + switch validateBeforeLayerAdd(sourceId: sourceId, layerId: layerId) { + case .failure(let error): + return .failure(error) + case .success(let (style, source)): + let layer = MLNHeatmapStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addHeatmapProperties( + heatmapLayer: layer, + properties: properties + ) + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + return .success(()) + } + } + + func addRasterLayer( + sourceId: String, + layerId: String, + belowLayerId: String?, + minimumZoomLevel: Double?, + maximumZoomLevel: Double?, + properties: [String: String] + ) -> Result { + switch validateBeforeLayerAdd(sourceId: sourceId, layerId: layerId) { + case .failure(let error): + return .failure(error) + case .success(let (style, source)): + let layer = MLNRasterStyleLayer(identifier: layerId, source: source) + LayerPropertyConverter.addRasterProperties( + rasterLayer: layer, + properties: properties + ) + if let minimumZoomLevel = minimumZoomLevel { + layer.minimumZoomLevel = Float(minimumZoomLevel) + } + if let maximumZoomLevel = maximumZoomLevel { + layer.maximumZoomLevel = Float(maximumZoomLevel) + } + if let id = belowLayerId, let belowLayer = style.layer(withIdentifier: id) { + style.insertLayer(layer, below: belowLayer) + } else { + style.addLayer(layer) + } + return .success(()) + } + } + + func addSource(sourceId: String, properties: [String: Any]) -> Result { + guard let style = mapView.style else { + return .failure(.styleNotFound) + } + guard style.source(withIdentifier: sourceId) == nil else { + return .failure(.sourceAlreadyExists(sourceId: sourceId)) + } + guard let type = properties["type"] as? String else { + return .failure(.invalidSourceType( + details: "Source '\(sourceId)' does not have a type." + )) + } + + var source: MLNSource? + switch type { + case "vector": + source = SourcePropertyConverter.buildVectorTileSource( + identifier: sourceId, + properties: properties + ) + case "raster": + source = SourcePropertyConverter.buildRasterTileSource( + identifier: sourceId, + properties: properties + ) + case "raster-dem": + source = SourcePropertyConverter.buildRasterDemSource( + identifier: sourceId, + properties: properties + ) + case "image": + source = SourcePropertyConverter.buildImageSource( + identifier: sourceId, + properties: properties + ) + case "geojson": + source = SourcePropertyConverter.buildShapeSource( + identifier: sourceId, + properties: properties + ) + default: + // unsupported source type + source = nil + } + if let source = source { + style.addSource(source) + return .success(()) + } + return .failure(.invalidSourceType( + details: "Source '\(sourceId)' does not support type '\(type)'." + )) + + } + + func mapViewDidBecomeIdle(_: MLNMapView) { + if let channel = channel { + channel.invokeMethod("map#onIdle", arguments: []) + } + } + + func mapView(_: MLNMapView, regionWillChangeAnimated _: Bool) { + if let channel = channel { + channel.invokeMethod("camera#onMoveStarted", arguments: []) + } + } + + func mapViewRegionIsChanging(_ mapView: MLNMapView) { + if !trackCameraPosition { return } + if let channel = channel { + channel.invokeMethod("camera#onMove", arguments: [ + "position": getCamera()?.toDict(mapView: mapView), + ]) + } + } + + func mapView(_ mapView: MLNMapView, regionDidChangeAnimated animated: Bool) { + let arguments = trackCameraPosition ? [ + "position": getCamera()?.toDict(mapView: mapView) + ] : [:] + if let channel = channel { + channel.invokeMethod("camera#onIdle", arguments: arguments) + } + } + + func addSourceGeojson(sourceId: String, geojson: String) -> Result { + do{ + guard let style = mapView.style else { + return .failure(.styleNotFound) + } + guard style.source(withIdentifier: sourceId) == nil else { + return .failure(.sourceAlreadyExists(sourceId: sourceId)) + } + + let parsed = try MLNShape( + data: geojson.data(using: .utf8)!, + encoding: String.Encoding.utf8.rawValue + ) + let source = MLNShapeSource(identifier: sourceId, shape: parsed, options: [:]) + addedShapesByLayer[sourceId] = parsed + style.addSource(source) + return .success(()) + } catch { + return .failure(.geojsonParseError(sourceId: sourceId)) + } + } + + func setSource(sourceId: String, geojson: String) -> Result { + guard let style = mapView.style else { + return .failure(.styleNotFound) + } + + do{ + let parsed = try MLNShape( + data: geojson.data(using: .utf8)!, + encoding: String.Encoding.utf8.rawValue + ) + guard let source = style.source(withIdentifier: sourceId) as? MLNShapeSource else { + return .failure(.sourceNotFound(sourceId: sourceId)) + } + addedShapesByLayer[sourceId] = parsed + source.shape = parsed + return .success(()) + }catch{ + return .failure(.geojsonParseError(sourceId: sourceId)) + } + + } + + + func setFeature(sourceId: String, geojsonFeature: String) -> Result { + guard let style = mapView.style else { + return .failure(.styleNotFound) + } + do { + let newShape = try MLNShape( + data: geojsonFeature.data(using: .utf8)!, + encoding: String.Encoding.utf8.rawValue + ) + guard let source = style.source(withIdentifier: sourceId) as? MLNShapeSource else { + return .failure(.sourceNotFound(sourceId: sourceId)) + } + if let shape = addedShapesByLayer[sourceId] as? MLNShapeCollectionFeature, + let feature = newShape as? MLNShape & MLNFeature + { + if let index = shape.shapes + .firstIndex(where: { + if let id = $0.identifier as? String, + let featureId = feature.identifier as? String + { return id == featureId } + + if let id = $0.identifier as? NSNumber, + let featureId = feature.identifier as? NSNumber + { return id == featureId } + return false + }) + { + var shapes = shape.shapes + shapes[index] = feature + + source.shape = MLNShapeCollectionFeature(shapes: shapes) + } + + addedShapesByLayer[sourceId] = source.shape + return .success(()) + } + return .failure(.genericError(details: "Failed to set feature for sourceId \(sourceId)")) + + } catch { + return .failure(.geojsonParseError(sourceId: sourceId)) + } + } + + /* + * MapLibreMapOptionsSink + */ + func setCameraTargetBounds(bounds: MLNCoordinateBounds?) { + let bounds = bounds ?? MLNCoordinateBounds( + sw: CLLocationCoordinate2D(latitude: -90, longitude: -180), + ne: CLLocationCoordinate2D(latitude: 90, longitude: 180) + ) + mapView.maximumScreenBounds = bounds; + } + + func setCompassEnabled(compassEnabled: Bool) { + mapView.compassView.isHidden = compassEnabled + mapView.compassView.isHidden = !compassEnabled + } + + func setMinMaxZoomPreference(min: Double?, max: Double?) { + // Use MapLibre defaults (0 for min, 22 for max) when unbounded (nil) + let minZoom = min ?? 0.0 + let maxZoom = max ?? 22.0 + + mapView.minimumZoomLevel = minZoom + mapView.maximumZoomLevel = maxZoom + } + + private static func styleStringIsJSON(_ styleString: String) -> Bool { + return styleString.hasPrefix("{") || styleString.hasPrefix("[") + } + + private static func styleStringAsURL( + _ styleString: String, + registrar: FlutterPluginRegistrar + ) -> URL? { + if styleString.isEmpty { + NSLog("styleStringAsURL - style string is empty, ignoring") + return nil + } else if styleStringIsJSON(styleString) { + return nil + } else if styleString.hasPrefix("/") { + // Absolute path + return URL(fileURLWithPath: styleString, isDirectory: false) + } else if !styleString.hasPrefix("http://"), + !styleString.hasPrefix("https://"), + !styleString.hasPrefix("mapbox://") + { + // We are assuming that the style will be loaded from an asset here. + let assetPath = registrar.lookupKey(forAsset: styleString) + return URL(string: assetPath, relativeTo: Bundle.main.resourceURL) + } else if (styleString.hasPrefix("file://")) { + if let path = Bundle.main.path( + forResource: styleString.deletingPrefix("file://"), + ofType: "json" + ) { + return URL(fileURLWithPath: path) + } else { + NSLog( + "styleStringAsURL - path not found: \(styleString), ignoring" + ) + return nil + } + } else { + return URL(string: styleString) + } + } + + func setStyleString(styleString: String) { + interactiveFeatureLayerIds.removeAll() + addedShapesByLayer.removeAll() + + if Self.styleStringIsJSON(styleString) { + mapView.styleJSON = styleString + } else if let url = Self.styleStringAsURL( + styleString, + registrar: registrar + ) { + mapView.styleURL = url; + } + } + + func setRotateGesturesEnabled(rotateGesturesEnabled: Bool) { + mapView.allowsRotating = rotateGesturesEnabled + } + + func setScrollGesturesEnabled(scrollGesturesEnabled: Bool) { + mapView.allowsScrolling = scrollGesturesEnabled + scrollingEnabled = scrollGesturesEnabled + } + + func setTiltGesturesEnabled(tiltGesturesEnabled: Bool) { + mapView.allowsTilting = tiltGesturesEnabled + } + + func setTrackCameraPosition(trackCameraPosition: Bool) { + self.trackCameraPosition = trackCameraPosition + } + + func setZoomGesturesEnabled(zoomGesturesEnabled: Bool) { + mapView.allowsZooming = zoomGesturesEnabled + } + + func setMyLocationEnabled(myLocationEnabled: Bool) { + if self.myLocationEnabled == myLocationEnabled { + return + } + self.myLocationEnabled = myLocationEnabled + updateMyLocationEnabled() + } + + func setMyLocationTrackingMode(myLocationTrackingMode: MLNUserTrackingMode) { + mapView.userTrackingMode = myLocationTrackingMode + } + + func setMyLocationRenderMode(myLocationRenderMode: MyLocationRenderMode) { + switch myLocationRenderMode { + case .Normal: + mapView.showsUserHeadingIndicator = false + case .Compass: + mapView.showsUserHeadingIndicator = true + case .Gps: + NSLog("RenderMode.GPS currently not supported") + } + } + + func setLogoEnabled(logoEnabled: Bool) { + mapView.logoView.isHidden = !logoEnabled + } + + func setLogoViewPosition(position: MLNOrnamentPosition) { + mapView.logoViewPosition = position + } + + func setLogoViewMargins(x: Double, y: Double) { + mapView.logoViewMargins = CGPoint(x: x, y: y) + } + + func setCompassViewPosition(position: MLNOrnamentPosition) { + mapView.compassViewPosition = position + } + + func setCompassViewMargins(x: Double, y: Double) { + mapView.compassViewMargins = CGPoint(x: x, y: y) + } + + func setAttributionButtonMargins(x: Double, y: Double) { + mapView.attributionButtonMargins = CGPoint(x: x, y: y) + } + + func setAttributionButtonPosition(position: MLNOrnamentPosition) { + mapView.attributionButtonPosition = position + } +} + +extension String { + func deletingPrefix(_ prefix: String) -> String { + guard self.hasPrefix(prefix) else { return self } + return String(self.dropFirst(prefix.count)) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapFactory.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapFactory.swift new file mode 100644 index 00000000..a292c4f2 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapFactory.swift @@ -0,0 +1,25 @@ +import Flutter + +class MapLibreMapFactory: NSObject, FlutterPlatformViewFactory { + var registrar: FlutterPluginRegistrar + + init(withRegistrar registrar: FlutterPluginRegistrar) { + self.registrar = registrar + super.init() + } + + func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + return FlutterStandardMessageCodec.sharedInstance() + } + + func create(withFrame frame: CGRect, viewIdentifier viewId: Int64, + arguments args: Any?) -> FlutterPlatformView + { + return MapLibreMapController( + withFrame: frame, + viewIdentifier: viewId, + arguments: args, + registrar: registrar + ) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapOptionsSink.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapOptionsSink.swift new file mode 100644 index 00000000..4625b127 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapOptionsSink.swift @@ -0,0 +1,23 @@ +import MapLibre + +protocol MapLibreMapOptionsSink { + func setCameraTargetBounds(bounds: MLNCoordinateBounds?) + func setCompassEnabled(compassEnabled: Bool) + func setStyleString(styleString: String) + func setMinMaxZoomPreference(min: Double?, max: Double?) + func setRotateGesturesEnabled(rotateGesturesEnabled: Bool) + func setScrollGesturesEnabled(scrollGesturesEnabled: Bool) + func setTiltGesturesEnabled(tiltGesturesEnabled: Bool) + func setTrackCameraPosition(trackCameraPosition: Bool) + func setZoomGesturesEnabled(zoomGesturesEnabled: Bool) + func setMyLocationEnabled(myLocationEnabled: Bool) + func setMyLocationTrackingMode(myLocationTrackingMode: MLNUserTrackingMode) + func setMyLocationRenderMode(myLocationRenderMode: MyLocationRenderMode) + func setLogoEnabled(logoEnabled: Bool) + func setLogoViewPosition(position: MLNOrnamentPosition) + func setLogoViewMargins(x: Double, y: Double) + func setCompassViewPosition(position: MLNOrnamentPosition) + func setCompassViewMargins(x: Double, y: Double) + func setAttributionButtonMargins(x: Double, y: Double) + func setAttributionButtonPosition(position: MLNOrnamentPosition) +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapsPlugin.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapsPlugin.swift new file mode 100644 index 00000000..802e4790 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MapLibreMapsPlugin.swift @@ -0,0 +1,170 @@ +import Flutter +import Foundation +import MapLibre +import UIKit + +public class MapLibreMapsPlugin: NSObject, FlutterPlugin { + static var downloadOfflineRegionChannelHandler: OfflineChannelHandler? = nil + + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = MapLibreMapFactory(withRegistrar: registrar) + registrar.register(instance, withId: "plugins.flutter.io/maplibre_gl") + + let channel = FlutterMethodChannel( + name: "plugins.flutter.io/maplibre_gl", + binaryMessenger: registrar.messenger() + ) + + channel.setMethodCallHandler { methodCall, result in + switch methodCall.method { + case "setHttpHeaders": + guard let arguments = methodCall.arguments as? [String: Any], + let headers = arguments["headers"] as? [String: String] + else { + result(FlutterError( + code: "setHttpHeadersError", + message: "could not decode arguments", + details: nil + )) + result(nil) + return + } + let sessionConfig = URLSessionConfiguration.default + sessionConfig.httpAdditionalHeaders = headers // your headers here + MLNNetworkConfiguration.sharedManager.sessionConfiguration = sessionConfig + result(nil) + case "installOfflineMapTiles": + guard let arguments = methodCall.arguments as? [String: String] else { return } + let tilesdb = arguments["tilesdb"] + installOfflineMapTiles(registrar: registrar, tilesdb: tilesdb!) + result(nil) + case "downloadOfflineRegion#setup": + guard let args = methodCall.arguments as? [String: Any], + let channelName = args["channelName"] as? String + else { + print( + "downloadOfflineRegion#setup unexpected arguments: \(String(describing: methodCall.arguments))" + ) + result(nil) + return + } + + downloadOfflineRegionChannelHandler = OfflineChannelHandler( + messenger: registrar.messenger(), + channelName: channelName + ) + + result(nil) + case "downloadOfflineRegion": + // Get download region arguments from caller + guard let args = methodCall.arguments as? [String: Any], + let definitionDictionary = args["definition"] as? [String: Any], + let metadata = args["metadata"] as? [String: Any], + let defintion = OfflineRegionDefinition.fromDictionary(definitionDictionary) + else { + print( + "downloadOfflineRegion unexpected arguments: \(String(describing: methodCall.arguments))" + ) + result(nil) + return + } + + if (downloadOfflineRegionChannelHandler == nil) { + result(FlutterError( + code: "downloadOfflineRegion#setup NOT CALLED", + message: "The setup has not been called, please call downloadOfflineRegion#setup before", + details: nil + )) + return + } + + OfflineManagerUtils.downloadRegion( + definition: defintion, + metadata: metadata, + result: result, + registrar: registrar, + channelHandler: downloadOfflineRegionChannelHandler! + ) + downloadOfflineRegionChannelHandler = nil; + case "setOfflineTileCountLimit": + guard let arguments = methodCall.arguments as? [String: Any], + let limit = arguments["limit"] as? UInt64 + else { + result(FlutterError( + code: "SetOfflineTileCountLimitError", + message: "could not decode arguments", + details: nil + )) + return + } + OfflineManagerUtils.setOfflineTileCountLimit(result: result, maximumCount: limit) + case "getListOfRegions": + // Note: this does not download anything from internet, it only fetches data drom database + OfflineManagerUtils.regionsList(result: result) + case "deleteOfflineRegion": + guard let args = methodCall.arguments as? [String: Any], + let id = args["id"] as? Int + else { + result(nil) + return + } + OfflineManagerUtils.deleteRegion(result: result, id: id) + default: + result(FlutterMethodNotImplemented) + } + } + } + + private static func getTilesUrl() -> URL { + guard var cachesUrl = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first, + let bundleId = Bundle.main + .object(forInfoDictionaryKey: kCFBundleIdentifierKey as String) as? String + else { + fatalError("Could not get map tiles directory") + } + cachesUrl.appendPathComponent(bundleId) + cachesUrl.appendPathComponent(".mapbox") + cachesUrl.appendPathComponent("cache.db") + return cachesUrl + } + + // Copies the "offline" tiles to where MapLibre expects them + private static func installOfflineMapTiles(registrar: FlutterPluginRegistrar, tilesdb: String) { + var tilesUrl = getTilesUrl() + let bundlePath = getTilesDbPath(registrar: registrar, tilesdb: tilesdb) + NSLog( + "Cached tiles not found, copying from bundle... \(String(describing: bundlePath)) ==> \(tilesUrl)" + ) + do { + let parentDir = tilesUrl.deletingLastPathComponent() + try FileManager.default.createDirectory( + at: parentDir, + withIntermediateDirectories: true, + attributes: nil + ) + if FileManager.default.fileExists(atPath: tilesUrl.path) { + try FileManager.default.removeItem(atPath: tilesUrl.path) + } + try FileManager.default.copyItem(atPath: bundlePath!, toPath: tilesUrl.path) + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + try tilesUrl.setResourceValues(resourceValues) + } catch { + NSLog("Error copying bundled tiles: \(error)") + } + } + + private static func getTilesDbPath(registrar: FlutterPluginRegistrar, + tilesdb: String) -> String? + { + if tilesdb.starts(with: "/") { + return tilesdb + } else { + let key = registrar.lookupKey(forAsset: tilesdb) + return Bundle.main.path(forResource: key, ofType: nil) + } + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MethodCallError.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MethodCallError.swift new file mode 100644 index 00000000..d97ad1d8 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/MethodCallError.swift @@ -0,0 +1,98 @@ +import Flutter + +enum MethodCallError: Error { + case genericError(details: String) + case invalidLayerType(details: String) + case invalidSourceType(details: String) + case invalidExpression + case sourceNotFound(sourceId: String) + case layerNotFound(layerId: String) + case styleNotFound + case sourceAlreadyExists(sourceId: String) + case layerAlreadyExists(layerId: String) + case geojsonParseError(sourceId: String) + + var code: String { + switch self { + case .genericError: + return "genericError" + case .invalidLayerType: + return "invalidLayerType" + case .invalidSourceType: + return "invalidSourceType" + case .invalidExpression: + return "invalidExpression" + case .sourceNotFound: + return "sourceNotFound" + case .layerNotFound: + return "layerNotFound" + case .styleNotFound: + return "styleNotFound" + case .sourceAlreadyExists: + return "sourceAlreadyExists" + case .layerAlreadyExists: + return "layerAlreadyExists" + case .geojsonParseError: + return "parseError" + + } + } + + var message: String { + switch self { + case .genericError: + return "Generic error" + case .invalidLayerType: + return "Invalid layer type" + case .invalidSourceType: + return "Invalid source type" + case .invalidExpression: + return "Invalid expression" + case .sourceNotFound: + return "Source not found" + case .layerNotFound: + return "Layer not found" + case .styleNotFound: + return "Style not found" + case .sourceAlreadyExists: + return "Source already exists" + case .layerAlreadyExists: + return "Layer already exists" + case .geojsonParseError: + return "Geojson parse error" + } + } + + var details: String { + switch self { + case let .genericError(details): + return details + case let .invalidLayerType(details): + return details + case let .invalidSourceType(details): + return details + case .invalidExpression: + return "Could not parse expression." + case let .sourceNotFound(sourceId): + return "Source with id \(sourceId) not found." + case let .layerNotFound(layerId): + return "Layer with id \(layerId) not found." + case .styleNotFound: + return "Style not found." + case let .sourceAlreadyExists(sourceId): + return "Source with id \(sourceId) already exists." + case let .layerAlreadyExists(layerId): + return "Layer with id \(layerId) already exists." + case let .geojsonParseError(sourceId): + return "Geojson parse error for source with id \(sourceId)." + } + } + + var flutterError: FlutterError { + return FlutterError( + code: code, + message: message, + details: details + ) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineChannelHandler.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineChannelHandler.swift new file mode 100644 index 00000000..b5f47466 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineChannelHandler.swift @@ -0,0 +1,69 @@ +// +// OfflineChannelHandler.swift +// location +// +// Created by Patryk on 03/06/2020. +// + +import Flutter +import Foundation + +class OfflineChannelHandler: NSObject, FlutterStreamHandler { + private var sink: FlutterEventSink? + + init(messenger: FlutterBinaryMessenger, channelName: String) { + super.init() + let eventChannel = FlutterEventChannel(name: channelName, binaryMessenger: messenger) + eventChannel.setStreamHandler(self) + } + + // MARK: FlutterStreamHandler protocol compliance + + func onListen(withArguments _: Any?, + eventSink events: @escaping FlutterEventSink) -> FlutterError? + { + sink = events + return nil + } + + func onCancel(withArguments _: Any?) -> FlutterError? { + sink = nil + return nil + } + + // MARK: Util methods + + func onError(errorCode: String, errorMessage: String?, errorDetails: Any?) { + sink?(FlutterError(code: errorCode, message: errorMessage, details: errorDetails)) + } + + func onSuccess() { + let body = ["status": "success"] + guard let jsonData = try? JSONSerialization.data( + withJSONObject: body, + options: .prettyPrinted + ), + let jsonString = String(data: jsonData, encoding: .utf8) else { return } + sink?(jsonString) + } + + func onStart() { + let body = ["status": "start"] + guard let jsonData = try? JSONSerialization.data( + withJSONObject: body, + options: .prettyPrinted + ), + let jsonString = String(data: jsonData, encoding: .utf8) else { return } + sink?(jsonString) + } + + func onProgress(progress: Double) { + let body: [String: Any] = ["status": "progress", "progress": progress] + guard let jsonData = try? JSONSerialization.data( + withJSONObject: body, + options: .prettyPrinted + ), + let jsonString = String(data: jsonData, encoding: .utf8) else { return } + sink?(jsonString) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineManagerUtils.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineManagerUtils.swift new file mode 100644 index 00000000..4a725d74 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineManagerUtils.swift @@ -0,0 +1,101 @@ +// +// OfflineManagerUtils.swift +// location +// +// Created by Patryk on 02/06/2020. +// + +import Flutter +import Foundation +import MapLibre + +class OfflineManagerUtils { + static var activeDownloaders: [Int: OfflinePackDownloader] = [:] + + static func downloadRegion( + definition: OfflineRegionDefinition, + metadata: [String: Any], + result: @escaping FlutterResult, + registrar _: FlutterPluginRegistrar, + channelHandler: OfflineChannelHandler + ) { + // Prepare downloader + let downloader = OfflinePackDownloader( + result: result, + channelHandler: channelHandler, + regionDefintion: definition, + metadata: metadata + ) + + // Download region + let id = downloader.download() + // retain downloader by its generated id + activeDownloaders[id] = downloader + } + + static func regionsList(result: @escaping FlutterResult) { + let offlineStorage = MLNOfflineStorage.shared + guard let packs = offlineStorage.packs else { + result("[]") + return + } + let regionsArgs = packs.compactMap { pack in + OfflineRegion.fromOfflinePack(pack)?.toDictionary() + } + guard let regionsArgsJsonData = try? JSONSerialization.data(withJSONObject: regionsArgs), + let regionsArgsJsonString = String(data: regionsArgsJsonData, encoding: .utf8) + else { + result(FlutterError(code: "RegionListError", message: nil, details: nil)) + return + } + result(regionsArgsJsonString) + } + + static func setOfflineTileCountLimit(result: @escaping FlutterResult, maximumCount: UInt64) { + let offlineStorage = MLNOfflineStorage.shared + offlineStorage.setMaximumAllowedMapboxTiles(maximumCount) + result(nil) + } + + static func deleteRegion(result: @escaping FlutterResult, id: Int) { + let offlineStorage = MLNOfflineStorage.shared + guard let pacs = offlineStorage.packs else { return } + let packToRemove = pacs.first(where: { pack -> Bool in + let contextJsonObject = try? JSONSerialization.jsonObject(with: pack.context) + let contextJsonDict = contextJsonObject as? [String: Any] + if let regionId = contextJsonDict?["id"] as? Int { + return regionId == id + } else { + return false + } + }) + if let packToRemoveUnwrapped = packToRemove { + // deletion is only safe if the download is suspended + packToRemoveUnwrapped.suspend() + OfflineManagerUtils.releaseDownloader(id: id) + + offlineStorage.removePack(packToRemoveUnwrapped) { error in + if let error = error { + result(FlutterError( + code: "DeleteRegionError", + message: error.localizedDescription, + details: nil + )) + } else { + result(nil) + } + } + } else { + result(FlutterError( + code: "DeleteRegionError", + message: "There is no region with given id to delete", + details: nil + )) + } + } + + /// Removes downloader from cache so it's memory can be deallocated + static func releaseDownloader(id: Int) { + activeDownloaders.removeValue(forKey: id) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflinePackDownloadManager.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflinePackDownloadManager.swift new file mode 100644 index 00000000..350dd3fb --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflinePackDownloadManager.swift @@ -0,0 +1,223 @@ +// +// OfflinePackDownloadManager.swift +// location +// +// Created by Patryk on 03/06/2020. +// + +import Flutter +import Foundation +import MapLibre + +class OfflinePackDownloader { + // MARK: Properties + + private let result: FlutterResult + private let channelHandler: OfflineChannelHandler + private let regionDefinition: OfflineRegionDefinition + private let metadata: [String: Any] + + /// Currently managed pack + private var pack: MLNOfflinePack? + + /// This variable is set to true when this downloader has finished downloading and called the result method. It is used to prevent + /// the result method being called multiple times + private var isCompleted = false + + // MARK: Initializers + + init( + result: @escaping FlutterResult, + channelHandler: OfflineChannelHandler, + regionDefintion: OfflineRegionDefinition, + metadata: [String: Any] + ) { + self.result = result + self.channelHandler = channelHandler + regionDefinition = regionDefintion + self.metadata = metadata + + setupNotifications() + } + + deinit { + print("Removing offline pack notification observers") + NotificationCenter.default.removeObserver(self) + } + + // MARK: Public methods + + func download() -> Int { + let storage = MLNOfflineStorage.shared + // While the Android SDK generates a region ID in createOfflineRegion, the iOS + // SDK does not have this feature. Therefore, we generate a region ID here. + let id = UUID().hashValue + let regionData = OfflineRegion(id: id, metadata: metadata, definition: regionDefinition) + let tilePyramidRegion = regionDefinition.toMLNTilePyramidOfflineRegion() + storage + .addPack(for: tilePyramidRegion, + withContext: regionData.prepareContext()) { [weak self] pack, error in + if let pack = pack { + self?.onPackCreated(pack: pack) + } else { + self?.onPackCreationError(error: error) + } + } + return id + } + + // MARK: Pack management + + private func onPackCreated(pack: MLNOfflinePack) { + if let region = OfflineRegion.fromOfflinePack(pack), + let regionData = try? JSONSerialization.data(withJSONObject: region.toDictionary()) + { + // Start downloading + self.pack = pack + pack.resume() + // Provide region with generated + result(String(data: regionData, encoding: .utf8)) + channelHandler.onStart() + } else { + onPackCreationError(error: OfflinePackError.InvalidPackData) + } + } + + private func onPackCreationError(error: Error?) { + // Reset downloading state + channelHandler.onError( + errorCode: "mapboxInvalidRegionDefinition", + errorMessage: error?.localizedDescription, + errorDetails: nil + ) + result(FlutterError( + code: "mapboxInvalidRegionDefinition", + message: error?.localizedDescription, + details: nil + )) + } + + // MARK: Progress obseration + + @objc private func onPackDownloadProgress(notification: NSNotification) { + // Verify if correct pack is checked + guard let pack = notification.object as? MLNOfflinePack, + verifyPack(pack: pack) else { return } + // Calculate progress of downloading + let packProgress = pack.progress + let downloadProgress = calculateDownloadingProgress( + requiredResourceCount: packProgress.countOfResourcesExpected, + completedResourceCount: packProgress.countOfResourcesCompleted + ) + // Check if downloading is complete + if pack.state == .complete { + print("Region downloaded successfully") + // set download state to inactive + // This can be called multiple times but result can only be called once. We use this + // check to ensure that + guard !isCompleted else { return } + isCompleted = true + channelHandler.onSuccess() + result(nil) + if let region = OfflineRegion.fromOfflinePack(pack) { + OfflineManagerUtils.releaseDownloader(id: region.id) + } + } else { + print("Region download progress \(downloadProgress)") + channelHandler.onProgress(progress: downloadProgress) + } + } + + @objc private func onPackDownloadError(notification: NSNotification) { + guard let pack = notification.object as? MLNOfflinePack, + verifyPack(pack: pack) else { return } + let error = notification.userInfo?[MLNOfflinePackUserInfoKey.error] as? NSError + print("Pack download error: \(String(describing: error?.localizedDescription))") + // set download state to inactive + isCompleted = true + channelHandler.onError( + errorCode: "Downloading error", + errorMessage: error?.localizedDescription, + errorDetails: nil + ) + result(FlutterError( + code: "Downloading error", + message: error?.localizedDescription, + details: nil + )) + if let region = OfflineRegion.fromOfflinePack(pack) { + OfflineManagerUtils.deleteRegion(result: result, id: region.id) + } + } + + @objc private func onMaximumAllowedMapboxTiles(notification: NSNotification) { + guard let pack = notification.object as? MLNOfflinePack, + verifyPack(pack: pack) else { return } + let maximumCount = (notification.userInfo?[MLNOfflinePackUserInfoKey.maximumCount] + as AnyObject).uint64Value ?? 0 + print("MapLibre tile count limit exceeded: \(maximumCount)") + // set download state to inactive + isCompleted = true + channelHandler.onError( + errorCode: "mapboxTileCountLimitExceeded", + errorMessage: "MapLibre tile count limit exceeded: \(maximumCount)", + errorDetails: nil + ) + result(FlutterError( + code: "mapboxTileCountLimitExceeded", + message: "MapLibre tile count limit exceeded: \(maximumCount)", + details: nil + )) + if let region = OfflineRegion.fromOfflinePack(pack) { + OfflineManagerUtils.deleteRegion(result: result, id: region.id) + } + } + + // MARK: Util methods + + private func setupNotifications() { + NotificationCenter.default.addObserver( + self, + selector: #selector(onPackDownloadProgress(notification:)), + name: NSNotification.Name.MLNOfflinePackProgressChanged, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(onPackDownloadError(notification:)), + name: NSNotification.Name.MLNOfflinePackError, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(onMaximumAllowedMapboxTiles(notification:)), + name: NSNotification.Name.MLNOfflinePackMaximumMapboxTilesReached, + object: nil + ) + } + + /// Since NotificationCenter will send notifications about all packs downloads we need to make sure we only handle packs + /// managed by this downloader. So this method checks if the pack we got from a notification is the same as the pack being + /// managed by this downloader and if it is it returns true. Otherwise it returns false + private func verifyPack(pack: MLNOfflinePack) -> Bool { + guard let currentlyManagedPack = self.pack else { + // No pack is being managed yet + return false + } + // We can tell whether 2 packs are the same by comparing metadata we assigned earlier + return pack.state != .invalid && pack.context == currentlyManagedPack.context + } + + private func calculateDownloadingProgress( + requiredResourceCount: UInt64, + completedResourceCount: UInt64 + ) -> Double { + return requiredResourceCount > 0 + ? 100.0 * Double(completedResourceCount) / Double(requiredResourceCount) + : 0.0 + } +} + +enum OfflinePackError: Error { + case InvalidPackData +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineRegion.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineRegion.swift new file mode 100644 index 00000000..1181366e --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineRegion.swift @@ -0,0 +1,57 @@ +// +// OfflineRegionData.swift +// location +// +// Created by Patryk on 02/06/2020. +// + +import Foundation +import MapLibre + +class OfflineRegion { + let id: Int + let metadata: [String: Any] + let definition: OfflineRegionDefinition + + enum CodingKeys: CodingKey { + case id, metadata, definition + } + + init(id: Int, metadata: [String: Any], definition: OfflineRegionDefinition) { + self.id = id + self.metadata = metadata + self.definition = definition + } + + func prepareContext() -> Data { + let context = ["metadata": metadata, "id": id] as [String: Any] + let jsonData = try? JSONSerialization.data(withJSONObject: context, options: []) + return jsonData ?? Data() + } + + func toDictionary() -> [String: Any] { + return [ + "id": id, + "metadata": metadata, + "definition": definition.toDictionary(), + ] + } + + static func fromOfflinePack(_ pack: MLNOfflinePack) -> OfflineRegion? { + guard let region = pack.region as? MLNTilePyramidOfflineRegion, + let dataObject = try? JSONSerialization.jsonObject(with: pack.context, options: []), + let dict = dataObject as? [String: Any], + let id = dict["id"] as? Int, + let metadata = dict["metadata"] as? [String: Any] else { return nil } + return OfflineRegion( + id: id, + metadata: metadata, + definition: OfflineRegionDefinition( + bounds: [region.bounds.sw, region.bounds.ne].map { [$0.latitude, $0.longitude] }, + mapStyleUrl: region.styleURL, + minZoom: region.minimumZoomLevel, + maxZoom: region.maximumZoomLevel + ) + ) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineRegionDefinition.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineRegionDefinition.swift new file mode 100644 index 00000000..9e57cde8 --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/OfflineRegionDefinition.swift @@ -0,0 +1,56 @@ +import Foundation +import MapLibre + +class OfflineRegionDefinition { + let bounds: [[Double]] + let mapStyleUrl: URL + let minZoom: Double + let maxZoom: Double + + init(bounds: [[Double]], mapStyleUrl: URL, minZoom: Double, maxZoom: Double) { + self.bounds = bounds + self.mapStyleUrl = mapStyleUrl + self.minZoom = minZoom + self.maxZoom = maxZoom + } + + func getBounds() -> MLNCoordinateBounds { + return MLNCoordinateBounds( + sw: CLLocationCoordinate2D(latitude: bounds[0][0], longitude: bounds[0][1]), + ne: CLLocationCoordinate2D(latitude: bounds[1][0], longitude: bounds[1][1]) + ) + } + + static func fromDictionary(_ jsonDict: [String: Any]) -> OfflineRegionDefinition? { + guard let bounds = jsonDict["bounds"] as? [[Double]], + let mapStyleUrlString = jsonDict["mapStyleUrl"] as? String, + let mapStyleUrl = URL(string: mapStyleUrlString), + let minZoom = jsonDict["minZoom"] as? Double, + let maxZoom = jsonDict["maxZoom"] as? Double + else { return nil } + return OfflineRegionDefinition( + bounds: bounds, + mapStyleUrl: mapStyleUrl, + minZoom: minZoom, + maxZoom: maxZoom + ) + } + + func toDictionary() -> [String: Any] { + return [ + "bounds": bounds, + "mapStyleUrl": mapStyleUrl.absoluteString, + "minZoom": minZoom, + "maxZoom": maxZoom, + ] + } + + func toMLNTilePyramidOfflineRegion() -> MLNTilePyramidOfflineRegion { + return MLNTilePyramidOfflineRegion( + styleURL: mapStyleUrl, + bounds: getBounds(), + fromZoomLevel: minZoom, + toZoomLevel: maxZoom + ) + } +} diff --git a/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/SourcePropertyConverter.swift b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/SourcePropertyConverter.swift new file mode 100644 index 00000000..a4c5ec9f --- /dev/null +++ b/third_party/maplibre_gl/ios/maplibre_gl/Sources/maplibre_gl/SourcePropertyConverter.swift @@ -0,0 +1,241 @@ +import Foundation +import MapLibre + +class SourcePropertyConverter { + class func interpretTileOptions(properties: [String: Any]) -> [MLNTileSourceOption: Any] { + var options = [MLNTileSourceOption: Any]() + + if let bounds = properties["bounds"] as? [Double] { + options[.coordinateBounds] = + NSValue(mlnCoordinateBounds: boundsFromArray(coordinates: bounds)) + } + if let minzoom = properties["minzoom"] as? Double { + options[.minimumZoomLevel] = minzoom + } + if let maxzoom = properties["maxzoom"] as? Double { + options[.maximumZoomLevel] = maxzoom + } + if let tileSize = properties["tileSize"] as? Double { + options[.tileSize] = Int(tileSize) + } + if let scheme = properties["scheme"] as? String { + let system: MLNTileCoordinateSystem = (scheme == "tms" ? .TMS : .XYZ) + options[.tileCoordinateSystem] = system.rawValue + } + if let attribution = properties["attribution"] as? String { + options[.attributionInfos] = parseAttributionHTML(attribution) + } + return options + } + + class func parseAttributionHTML(_ html: String) -> [MLNAttributionInfo] { + // Parse HTML attribution string and create a single attributed string with clickable links + let pattern = #"]*>([^<]+)"# + + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + // Regex failed, use plain text + let title = NSAttributedString(string: html) + return [MLNAttributionInfo(title: title, url: nil)] + } + + let nsString = html as NSString + let matches = regex.matches(in: html, options: [], range: NSRange(location: 0, length: nsString.length)) + + if matches.isEmpty { + // No links found, create simple attribution + let title = NSAttributedString(string: html) + return [MLNAttributionInfo(title: title, url: nil)] + } + + // Build a single attributed string with all text and apply link attributes + let attributedString = NSMutableAttributedString() + var lastLocation = 0 + var primaryURL: URL? + + for match in matches { + // Add text before the link + if match.range.location > lastLocation { + let plainRange = NSRange(location: lastLocation, length: match.range.location - lastLocation) + let plainText = nsString.substring(with: plainRange) + attributedString.append(NSAttributedString(string: plainText)) + } + + // Add the link text with link attribute + if match.numberOfRanges >= 3 { + let urlRange = match.range(at: 1) + let textRange = match.range(at: 2) + + let urlString = nsString.substring(with: urlRange) + let linkText = nsString.substring(with: textRange) + + if let url = URL(string: urlString) { + if primaryURL == nil { + primaryURL = url + } + let linkAttributedString = NSMutableAttributedString(string: linkText) + linkAttributedString.addAttribute(.link, value: url, range: NSRange(location: 0, length: linkText.count)) + attributedString.append(linkAttributedString) + } + } + + lastLocation = match.range.location + match.range.length + } + + // Add any remaining text after the last link + if lastLocation < nsString.length { + let remainingText = nsString.substring(from: lastLocation) + attributedString.append(NSAttributedString(string: remainingText)) + } + + return [MLNAttributionInfo(title: attributedString, url: primaryURL)] + } + + class func buildRasterTileSource(identifier: String, + properties: [String: Any]) -> MLNRasterTileSource? + { + if let rawUrl = properties["url"] as? String, let url = URL(string: rawUrl) { + return MLNRasterTileSource(identifier: identifier, configurationURL: url) + } + if let tiles = properties["tiles"] as? [String] { + let options = interpretTileOptions(properties: properties) + return MLNRasterTileSource( + identifier: identifier, + tileURLTemplates: tiles, + options: options + ) + } + return nil + } + + class func buildVectorTileSource(identifier: String, + properties: [String: Any]) -> MLNVectorTileSource? + { + if let rawUrl = properties["url"] as? String, let url = URL(string: rawUrl) { + return MLNVectorTileSource(identifier: identifier, configurationURL: url) + } + if let tiles = properties["tiles"] as? [String] { + return MLNVectorTileSource( + identifier: identifier, + tileURLTemplates: tiles, + options: interpretTileOptions(properties: properties) + ) + } + return nil + } + + class func buildRasterDemSource(identifier: String, + properties: [String: Any]) -> MLNRasterDEMSource? + { + if let rawUrl = properties["url"] as? String, let url = URL(string: rawUrl) { + return MLNRasterDEMSource(identifier: identifier, configurationURL: url) + } + if let tiles = properties["tiles"] as? [String] { + return MLNRasterDEMSource( + identifier: identifier, + tileURLTemplates: tiles, + options: interpretTileOptions(properties: properties) + ) + } + return nil + } + + class func interpretShapeOptions(properties: [String: Any]) -> [MLNShapeSourceOption: Any] { + var options = [MLNShapeSourceOption: Any]() + + if let maxzoom = properties["maxzoom"] as? Double { + options[.maximumZoomLevel] = maxzoom + } + + if let buffer = properties["buffer"] as? Double { + options[.buffer] = buffer + } + if let tolerance = properties["tolerance"] as? Double { + options[.simplificationTolerance] = tolerance + } + + if let cluster = properties["cluster"] as? Bool { + options[.clustered] = cluster + } + if let clusterRadius = properties["clusterRadius"] as? Double { + options[.clusterRadius] = clusterRadius + } + if let clusterMaxZoom = properties["clusterMaxZoom"] as? Double { + options[.maximumZoomLevelForClustering] = clusterMaxZoom + } + + // TODO: clusterProperties not implemneted for IOS + + if let lineMetrics = properties["lineMetrics"] as? Bool { + options[.lineDistanceMetrics] = lineMetrics + } + return options + } + + class func buildShapeSource(identifier: String, properties: [String: Any]) -> MLNShapeSource? { + let options = interpretShapeOptions(properties: properties) + if let data = properties["data"] as? String, let url = URL(string: data) { + return MLNShapeSource(identifier: identifier, url: url, options: options) + } + if let data = properties["data"] { + do { + let geoJsonData = try JSONSerialization.data(withJSONObject: data) + let shape = try MLNShape(data: geoJsonData, encoding: String.Encoding.utf8.rawValue) + return MLNShapeSource(identifier: identifier, shape: shape, options: options) + } catch {} + } + return nil + } + + class func buildImageSource(identifier: String, properties: [String: Any]) -> MLNImageSource? { + if let rawUrl = properties["url"] as? String, let url = URL(string: rawUrl), + let coordinates = properties["coordinates"] as? [[Double]] + { + return MLNImageSource( + identifier: identifier, + coordinateQuad: quadFromArray(coordinates: coordinates), + url: url + ) + } + return nil + } + + class func addShapeProperties(properties: [String: Any], source: MLNShapeSource) { + do { + if let data = properties["data"] as? String { + let parsed = try MLNShape( + data: data.data(using: .utf8)!, + encoding: String.Encoding.utf8.rawValue + ) + source.shape = parsed + } + } catch {} + } + + class func quadFromArray(coordinates: [[Double]]) -> MLNCoordinateQuad { + return MLNCoordinateQuad( + topLeft: CLLocationCoordinate2D( + latitude: coordinates[0][1], + longitude: coordinates[0][0] + ), + bottomLeft: CLLocationCoordinate2D( + latitude: coordinates[3][1], + longitude: coordinates[3][0] + ), + bottomRight: CLLocationCoordinate2D( + latitude: coordinates[2][1], + longitude: coordinates[2][0] + ), + topRight: CLLocationCoordinate2D( + latitude: coordinates[1][1], + longitude: coordinates[1][0] + ) + ) + } + + class func boundsFromArray(coordinates: [Double]) -> MLNCoordinateBounds { + return MLNCoordinateBounds( + sw: CLLocationCoordinate2D(latitude: coordinates[1], longitude: coordinates[0]), + ne: CLLocationCoordinate2D(latitude: coordinates[3], longitude: coordinates[2]) + ) + } +} diff --git a/third_party/maplibre_gl/lib/maplibre_gl.dart b/third_party/maplibre_gl/lib/maplibre_gl.dart new file mode 100644 index 00000000..f7e93044 --- /dev/null +++ b/third_party/maplibre_gl/lib/maplibre_gl.dart @@ -0,0 +1,109 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// This library contains the MapLibre GL plugin for Flutter. +/// +/// To display a map, add a [MapLibreMap] widget to the widget tree. +/// +/// In this plugin, the map is configured through the parameters passed to the [MapLibreMap] constructor and through the [MapLibreMapController]. +/// The [MapLibreMapController] is provided at runtime by the [MapLibreMap.onMapCreated] callback. +/// The controller also allows adding annotations (icons, lines etc.) to the map at runtime and provides some callbacks to get notified when the user clicks those. +/// +/// The visual appearance of the map is configured through a MapLibre style passed to the +/// [styleString] parameter of the [MapLibreMap] constructor. +/// The MapLibre style is a JSON document following the specification at https://maplibre.org/maplibre-style-spec/. +/// The following is supposed to serve as a short introduction to the MapLibre style specification: +/// The style document contains (among other things) sources and layers. +/// Sources determine which data is displayed on the map, layers determine how the data is displayed. +/// +/// Typical types of sources are raster and vector tiles, as well as GeoJson data. +/// For raster and vector tiles, the entire world is divided into a set of tiles in different zoom levels. +/// Depending on the map's zoom level and viewport, the MapLibre client libraries decide, which tiles are needed to fill the viewport and request them from the source. +/// +/// The difference between raster and vector tiles is that raster tiles are images that are pre-rendered on a server, whereas vector tiles contain raw geometric information that is rendered on the client. +/// Vector tiles are in the Mapbox Vector Tile (MVT) format, the de-facto standard for vector tiles that is implemented by multiple libraries. +/// +/// Vector tiles have a number of advantages over raster tiles, including (often) smaller size, +/// the possibility to style them dynamically at runtime (e.g. change the color or visibility of certain features), +/// and the possibility to rotate them and keep text labels horizontal. +/// Raster and vector tiles can be generated from a variety of sources, including OpenStreetMap data and are also available from a number of providers. +/// +/// Raster sources are displayed by adding a "raster" layer to the MapLibre GL style. +/// Vector and GeoJson sources are displayed by adding a "line", "fill", "symbol" or "circle" layer to the MapLibre GL style and specifying +/// which source to use (by setting the "source" property of the layer to the id of the source) as well as how to style the data by setting other properties of the layer such as "line-color" or "fill-outline-color". +/// For example, a vector source layer (or a GeoJson source layer) with the outlines of countries could be displayed both by a fill layer to fill the countries with a color and by a line layer to draw the outlines of the countries. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:maplibre_gl_platform_interface/maplibre_gl_platform_interface.dart'; + +export 'package:maplibre_gl_platform_interface/maplibre_gl_platform_interface.dart' + show + Annotation, + ArgumentCallbacks, + AttributionButtonPosition, + CameraPosition, + CameraTargetBounds, + CameraUpdate, + Circle, + CircleOptions, + CompassViewPosition, + Fill, + FillOptions, + GeojsonSourceProperties, + ImageSourceProperties, + LatLng, + LatLngBounds, + LatLngQuad, + Line, + LineOptions, + LocationEngineAndroidProperties, + LocationEnginePlatforms, + LocationPriority, + LogoViewPosition, + MapLibreMethodChannel, + MapLibrePlatform, + MinMaxZoomPreference, + MyLocationRenderMode, + MyLocationTrackingMode, + OnPlatformViewCreatedCallback, + RasterDemSourceProperties, + RasterSourceProperties, + SourceProperties, + Symbol, + SymbolOptions, + UserHeading, + UserLocation, + VectorSourceProperties, + VideoSourceProperties; + +part 'src/controller.dart'; + +part 'src/maplibre_map.dart'; + +part 'src/global.dart'; + +part 'src/offline_region.dart'; + +part 'src/download_region_status.dart'; + +part 'src/layer_expressions.dart'; + +part 'src/layer_properties.dart'; + +part 'src/color_tools.dart'; + +part 'src/annotation_manager.dart'; + +part 'src/util.dart'; + +part 'src/maplibre_styles.dart'; diff --git a/third_party/maplibre_gl/lib/src/annotation_manager.dart b/third_party/maplibre_gl/lib/src/annotation_manager.dart new file mode 100644 index 00000000..8ab46870 --- /dev/null +++ b/third_party/maplibre_gl/lib/src/annotation_manager.dart @@ -0,0 +1,376 @@ +part of '../maplibre_gl.dart'; + +/// Manages a homogeneous set of [Annotation]s (e.g. symbols, lines, fills) by +/// owning their backing style source(s)/layer(s) and performing efficient +/// batched updates. +/// +/// The [initialize] method must be called before [AnnotationManager] instance +/// can be used. Once [AnnotationManager] is initialized, the [isInitialized] +/// getter will return true. +/// +/// An [AnnotationManager] keeps an internal mapping from annotation id to its +/// model object and mirrors the collection into one or more GeoJSON sources; +/// each source is bound to a style layer whose visual properties come from +/// [allLayerProperties]. +/// +/// When [enableInteraction] is true, drag events are listened to and the +/// underlying annotation is translated & re-set. +abstract class AnnotationManager { + final MapLibreMapController controller; + + bool _isInitializing = false; + bool _isInitialized = false; + final _idToAnnotation = {}; + final _idToLayerIndex = {}; + + /// Base identifier of the manager. Use [layerIds] for concrete layer ids. + final String id; + + /// Tracks whether the manager and its layers were initialized. + bool get isInitialized => _isInitialized; + + List get layerIds => + [for (int i = 0; i < allLayerProperties.length; i++) _makeLayerId(i)]; + + /// If false, the manager disables user interaction (e.g. dragging) for + /// its annotations. + final bool enableInteraction; + + /// Layer property definitions (one per backing style layer). Override in + /// subclasses to specify visual styling for data-driven attributes. + List get allLayerProperties; + + /// Optional function used to select which layer/source a given annotation + /// should live in (e.g. pattern vs non-pattern lines). If null, a single + /// layer/source is used. + final int Function(T)? selectLayer; + + /// Returns the annotation with the given [id], or null if not found. + T? byId(String id) => _idToAnnotation[id]; + + /// Current set of managed annotations. + Set get annotations => _idToAnnotation.values.toSet(); + + AnnotationManager( + this.controller, { + this.selectLayer, + required this.enableInteraction, + }) : id = getRandomString(); + + @mustCallSuper + Future initialize() async { + if (_isInitializing || _isInitialized) { + return; + } + + // Mark initialization process start, so that it cannot be entered again + _isInitializing = true; + + try { + for (var i = 0; i < allLayerProperties.length; i++) { + final layerId = _makeLayerId(i); + + await controller.addGeoJsonSource( + layerId, + buildFeatureCollection([]), + promoteId: "id", + ); + await controller.addLayer( + layerId, + layerId, + allLayerProperties[i], + enableInteraction: enableInteraction, + ); + } + + controller.onFeatureDrag.add(_onDrag); + + // Mark as initialized + _isInitialized = true; + } finally { + // Mark initialization process end + _isInitializing = false; + } + } + + /// Rebuilds all backing style layers (e.g. after overlap settings changed). + Future _rebuildLayers() async { + for (var i = 0; i < allLayerProperties.length; i++) { + final layerId = _makeLayerId(i); + await controller.removeLayer(layerId); + await controller.addLayer(layerId, layerId, allLayerProperties[i], + enableInteraction: enableInteraction); + } + } + + String _makeLayerId(int layerIndex) => "${id}_$layerIndex"; + + Future _setAll() async { + if (selectLayer != null) { + final featureBuckets = [for (final _ in allLayerProperties) []]; + + for (final annotation in _idToAnnotation.values) { + final layerIndex = selectLayer!(annotation); + _idToLayerIndex[annotation.id] = layerIndex; + featureBuckets[layerIndex].add(annotation); + } + + for (var i = 0; i < featureBuckets.length; i++) { + await controller.setGeoJsonSource( + _makeLayerId(i), + buildFeatureCollection( + [for (final l in featureBuckets[i]) l.toGeoJson()])); + } + } else { + await controller.setGeoJsonSource( + _makeLayerId(0), + buildFeatureCollection( + [for (final l in _idToAnnotation.values) l.toGeoJson()])); + } + } + + /// Adds multiple annotations (faster than adding one-by-one). + Future addAll(Iterable annotations) async { + for (final a in annotations) { + _idToAnnotation[a.id] = a; + } + await _setAll(); + } + + /// Adds a single annotation. + Future add(T annotation) async { + _idToAnnotation[annotation.id] = annotation; + await _setAll(); + } + + /// Removes multiple annotations. + Future removeAll(Iterable annotations) async { + for (final a in annotations) { + _idToAnnotation.remove(a.id); + } + await _setAll(); + } + + /// Removes a single annotation. + Future remove(T annotation) async { + _idToAnnotation.remove(annotation.id); + await _setAll(); + } + + /// Removes all annotations. + Future clear() async { + _idToAnnotation.clear(); + await _setAll(); + } + + /// Fully dispose resources (layers & sources). Manager is unusable after. + Future dispose() async { + _idToAnnotation.clear(); + await _setAll(); + for (var i = 0; i < allLayerProperties.length; i++) { + await controller.removeLayer(_makeLayerId(i)); + await controller.removeSource(_makeLayerId(i)); + } + } + + Future _onDrag( + Point point, + LatLng origin, + LatLng current, + LatLng delta, + String id, + Annotation? annotation, + DragEventType eventType, + ) async { + if (annotation is T) { + annotation.translate(delta); + await set(annotation); + } + } + + /// Updates (re-sets) an existing annotation quickly by only replacing its + /// underlying GeoJSON feature if it remains on the same logical layer. + Future set(T annotation) async { + assert(_idToAnnotation.containsKey(annotation.id), + "you can only set existing annotations"); + _idToAnnotation[annotation.id] = annotation; + final oldLayerIndex = _idToLayerIndex[annotation.id]; + final layerIndex = selectLayer != null ? selectLayer!(annotation) : 0; + if (oldLayerIndex != layerIndex) { + // Layer changed; must rewrite all sources. + await _setAll(); + } else { + await controller.setGeoJsonFeature( + _makeLayerId(layerIndex), annotation.toGeoJson()); + } + } +} + +class LineManager extends AnnotationManager { + LineManager( + super.controller, { + super.enableInteraction = true, + }) : super( + selectLayer: (Line line) => line.options.linePattern == null ? 0 : 1, + ); + + static const _baseProperties = LineLayerProperties( + lineJoin: [Expressions.get, 'lineJoin'], + lineOpacity: [Expressions.get, 'lineOpacity'], + lineColor: [Expressions.get, 'lineColor'], + lineWidth: [Expressions.get, 'lineWidth'], + lineGapWidth: [Expressions.get, 'lineGapWidth'], + lineOffset: [Expressions.get, 'lineOffset'], + lineBlur: [Expressions.get, 'lineBlur'], + ); + + @override + List get allLayerProperties => [ + _baseProperties, + _baseProperties.copyWith(const LineLayerProperties( + linePattern: [Expressions.get, 'linePattern'])), + ]; +} + +class FillManager extends AnnotationManager { + FillManager( + super.controller, { + super.enableInteraction = true, + }) : super( + selectLayer: (Fill fill) => fill.options.fillPattern == null ? 0 : 1, + ); + + @override + List get allLayerProperties => const [ + FillLayerProperties( + fillOpacity: [Expressions.get, 'fillOpacity'], + fillColor: [Expressions.get, 'fillColor'], + fillOutlineColor: [Expressions.get, 'fillOutlineColor'], + ), + FillLayerProperties( + fillOpacity: [Expressions.get, 'fillOpacity'], + fillColor: [Expressions.get, 'fillColor'], + fillOutlineColor: [Expressions.get, 'fillOutlineColor'], + fillPattern: [Expressions.get, 'fillPattern'], + ) + ]; +} + +class CircleManager extends AnnotationManager { + CircleManager( + super.controller, { + super.enableInteraction = true, + }); + + @override + List get allLayerProperties => const [ + CircleLayerProperties( + circleRadius: [Expressions.get, 'circleRadius'], + circleColor: [Expressions.get, 'circleColor'], + circleBlur: [Expressions.get, 'circleBlur'], + circleOpacity: [Expressions.get, 'circleOpacity'], + circleStrokeWidth: [Expressions.get, 'circleStrokeWidth'], + circleStrokeColor: [Expressions.get, 'circleStrokeColor'], + circleStrokeOpacity: [Expressions.get, 'circleStrokeOpacity'], + ) + ]; +} + +class SymbolManager extends AnnotationManager { + SymbolManager( + super.controller, { + bool iconAllowOverlap = false, + bool textAllowOverlap = false, + bool iconIgnorePlacement = false, + bool textIgnorePlacement = false, + super.enableInteraction = true, + }) : _iconAllowOverlap = iconAllowOverlap, + _textAllowOverlap = textAllowOverlap, + _iconIgnorePlacement = iconIgnorePlacement, + _textIgnorePlacement = textIgnorePlacement; + + bool _iconAllowOverlap; + bool _textAllowOverlap; + bool _iconIgnorePlacement; + bool _textIgnorePlacement; + + /// If true, the icon will be visible even if it collides with other previously drawn symbols. + Future setIconAllowOverlap(bool value) async { + if (value == _iconAllowOverlap) return; + + _iconAllowOverlap = value; + await _rebuildLayers(); + } + + /// If true, other symbols can be visible even if they collide with the icon. + Future setTextAllowOverlap(bool value) async { + if (value == _textAllowOverlap) return; + + _textAllowOverlap = value; + await _rebuildLayers(); + } + + /// If true, the text will be visible even if it collides with other previously drawn symbols. + Future setIconIgnorePlacement(bool value) async { + if (value == _iconIgnorePlacement) return; + + _iconIgnorePlacement = value; + await _rebuildLayers(); + } + + /// If true, other symbols can be visible even if they collide with the text. + Future setTextIgnorePlacement(bool value) async { + if (value == _textIgnorePlacement) return; + + _textIgnorePlacement = value; + await _rebuildLayers(); + } + + @override + List get allLayerProperties => [ + SymbolLayerProperties( + iconSize: [Expressions.get, 'iconSize'], + iconImage: [Expressions.get, 'iconImage'], + iconRotate: [Expressions.get, 'iconRotate'], + iconOffset: [Expressions.get, 'iconOffset'], + iconAnchor: [Expressions.get, 'iconAnchor'], + iconOpacity: [Expressions.get, 'iconOpacity'], + iconColor: [Expressions.get, 'iconColor'], + iconHaloColor: [Expressions.get, 'iconHaloColor'], + iconHaloWidth: [Expressions.get, 'iconHaloWidth'], + iconHaloBlur: [Expressions.get, 'iconHaloBlur'], + // note that web does not support setting this in a fully data driven + // way this is a upstream issue + textFont: kIsWeb + ? null + : [ + Expressions.caseExpression, + [Expressions.has, 'fontNames'], + [Expressions.get, 'fontNames'], + [ + Expressions.literal, + ["Open Sans Regular", "Arial Unicode MS Regular"] + ], + ], + textField: [Expressions.get, 'textField'], + textSize: [Expressions.get, 'textSize'], + textMaxWidth: [Expressions.get, 'textMaxWidth'], + textLetterSpacing: [Expressions.get, 'textLetterSpacing'], + textJustify: [Expressions.get, 'textJustify'], + textAnchor: [Expressions.get, 'textAnchor'], + textRotate: [Expressions.get, 'textRotate'], + textTransform: [Expressions.get, 'textTransform'], + textOffset: [Expressions.get, 'textOffset'], + textOpacity: [Expressions.get, 'textOpacity'], + textColor: [Expressions.get, 'textColor'], + textHaloColor: [Expressions.get, 'textHaloColor'], + textHaloWidth: [Expressions.get, 'textHaloWidth'], + textHaloBlur: [Expressions.get, 'textHaloBlur'], + symbolSortKey: [Expressions.get, 'zIndex'], + iconAllowOverlap: _iconAllowOverlap, + iconIgnorePlacement: _iconIgnorePlacement, + textAllowOverlap: _textAllowOverlap, + textIgnorePlacement: _textIgnorePlacement, + ) + ]; +} diff --git a/third_party/maplibre_gl/lib/src/color_tools.dart b/third_party/maplibre_gl/lib/src/color_tools.dart new file mode 100644 index 00000000..026302cb --- /dev/null +++ b/third_party/maplibre_gl/lib/src/color_tools.dart @@ -0,0 +1,12 @@ +// ignore_for_file: deprecated_member_use --- IGNORE --- + +part of '../maplibre_gl.dart'; + +extension MapLibreColorConversion on Color { + String toHexStringRGB() { + final r = red.toRadixString(16).padLeft(2, '0'); + final g = green.toRadixString(16).padLeft(2, '0'); + final b = blue.toRadixString(16).padLeft(2, '0'); + return '#$r$g$b'; + } +} diff --git a/third_party/maplibre_gl/lib/src/controller.dart b/third_party/maplibre_gl/lib/src/controller.dart new file mode 100644 index 00000000..f511b3cd --- /dev/null +++ b/third_party/maplibre_gl/lib/src/controller.dart @@ -0,0 +1,1721 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +part of '../maplibre_gl.dart'; + +typedef OnMapClickCallback = void Function( + Point point, LatLng coordinates); + +// New generalized feature interaction callback that always provides a raw feature id. +// If the feature also corresponds to a managed annotation, the annotation parameter +// is non-null; otherwise it is null (e.g. raw style layer feature not managed by an AnnotationManager). +typedef OnFeatureInteractionCallback = void Function( + Point point, + LatLng coordinates, + String id, + String layerId, + Annotation? annotation, +); + +typedef OnFeatureDragCallback = void Function( + Point point, + LatLng origin, + LatLng current, + LatLng delta, + String id, + Annotation? annotation, + DragEventType eventType, +); + +typedef OnFeatureHoverCallback = void Function( + Point point, + LatLng coordinates, + String id, + Annotation? annotation, + HoverEventType eventType, +); + +typedef OnMapLongClickCallback = void Function( + Point point, LatLng coordinates); + +typedef OnStyleLoadedCallback = void Function(); + +typedef OnUserLocationUpdated = void Function(UserLocation location); + +typedef OnCameraTrackingDismissedCallback = void Function(); +typedef OnCameraTrackingChangedCallback = void Function( + MyLocationTrackingMode mode); + +typedef OnCameraMoveCallback = void Function(CameraPosition cameraPosition); + +typedef OnCameraIdleCallback = void Function(); + +typedef OnMapIdleCallback = void Function(); + +@Deprecated('MaplibreMapController was renamed to MapLibreMapController.') +typedef MaplibreMapController = MapLibreMapController; + +/// Controller for a single [MapLibreMap] instance running on the host platform. +/// +/// Some of its methods can only be called after the [onStyleLoaded] callback has been invoked. +/// +/// To add annotations ([Circle]s, [Line]s, [Symbol]s and [Fill]s) on the map, there are two ways: +/// +/// 1. *Simple way to add annotations*: Use the corresponding add* methods ([addCircle], [addLine], [addSymbol] and [addFill]) on the MapLibreMapController to add one annotation at a time to the map. +/// There are also corresponding [addCircles], [addLines] etc. methods which work the same but add multiple annotations at a time. +/// +/// (If you are interested how this works: under the hood, this uses AnnotationManagers to manage the annotations. +/// An annotation manager performs the steps from the advanced way, but hides the complexity from the developer. +/// E.g. the [addCircle] method uses the [CircleManager], which in turn adds a GeoJson source to the map's style with the circle's locations as features. +/// The CircleManager also adds a circle style layer to the map's style that references that GeoJson source, therefore rendering all circles added with [addCircle] on the map.) +/// +/// There are also corresponding clear* methods like [clearCircles] to remove all circles from the map, which had been added with [addCircle] or [addCircles]. +/// +/// There are also properties like [circles] to get the current set of circles on the map, which had been added with [addCircle] or [addCircles]. +/// +/// Click events on annotations that are added this way (with the [addCircle], [addLine] etc. methods) can be received by adding callbacks to [onCircleTapped], [onLineTapped] etc. +/// +/// Note: [circles], [clearCircles] and [onCircleTapped] only work for circles added with [addCircle] or [addCircles], +/// not for circles that are already contained in the map's style when the map is loaded or are added to that map's style with the methods from the advanced way (see below). +/// The same of course applies for fills, lines and symbols. +/// +/// 2. *Advanced way to add annotations*: Modify the underlying MapLibre Style of the map to add a new data source (e.g. with the [addSource] method or the more specific methods like [addGeoJsonSource]) +/// and add a new layer to display the data of that source on the map (either with the [addLayer] method or with the more specific methods like [addCircleLayer], [addLineLayer] etc.). +/// For more information about MapLibre Styles, see the documentation of [maplibre_gl] as well as the specification at [https://maplibre.org/maplibre-style-spec/]. +/// +/// A MapLibreMapController is also a [ChangeNotifier]. Subscribers (change listeners) are notified upon changes to any of +/// +/// * the configuration options of the [MapLibreMap] widget +/// * the [symbols], [lines], [circles] or [fills] properties +/// (i.e. the collection of [Symbol]s, [Line]s, [Circle]s and [Fill]s added to this map via the "simple way" (see above)) +/// * the [isCameraMoving] property +/// * the [cameraPosition] property +/// +/// Listeners are notified after changes have been applied on the platform side. +class MapLibreMapController extends ChangeNotifier { + MapLibreMapController({ + required MapLibrePlatform maplibrePlatform, + required CameraPosition initialCameraPosition, + required Iterable annotationOrder, + required Iterable annotationConsumeTapEvents, + this.onStyleLoadedCallback, + this.onMapClick, + this.onMapLongClick, + this.onCameraTrackingDismissed, + this.onCameraTrackingChanged, + this.onMapIdle, + this.onUserLocationUpdated, + this.onCameraIdle, + this.onCameraMove, + }) : _maplibrePlatform = maplibrePlatform { + _cameraPosition = initialCameraPosition; + + _maplibrePlatform.onFeatureTappedPlatform.add((payload) { + final id = payload["id"].toString(); + final layerId = payload["layerId"]; + final point = payload["point"]; + final latLng = payload["latLng"]; + final annotation = getAnnotationById(id); + + // Call all generic feature tapped callbacks + for (final fun in List.of(onFeatureTapped)) { + // New signature supplies id and (possibly null) annotation + fun(point, latLng, id, layerId, annotation); + } + + // If we have a managed annotation, call specific annotation callbacks (onSymbolTapped, onLineTapped...) + if (annotation != null) { + ArgumentCallbacks? annotationTappedCallbacks; + if (annotation is Line) { + annotationTappedCallbacks = onLineTapped; + } else if (annotation is Symbol) { + annotationTappedCallbacks = onSymbolTapped; + } else if (annotation is Fill) { + annotationTappedCallbacks = onFillTapped; + } else if (annotation is Circle) { + annotationTappedCallbacks = onCircleTapped; + } + annotationTappedCallbacks?.call(annotation); + } + }); + + _maplibrePlatform.onFeatureDraggedPlatform.add((payload) { + final id = payload["id"]; + final annotation = getAnnotationById(id); + final enmDragEventType = DragEventType.values + .firstWhere((element) => element.name == payload["eventType"]); + for (final fun in List.of(onFeatureDrag)) { + fun( + payload["point"], + payload["origin"], + payload["current"], + payload["delta"], + id, + annotation, + enmDragEventType, + ); + } + }); + + _maplibrePlatform.onFeatureHoverPlatform.add((payload) { + final id = payload["id"]; + final annotation = getAnnotationById(id); + final hoverEventType = HoverEventType.values + .firstWhere((e) => e.name == payload["eventType"]); + for (final fun in List.of(onFeatureHover)) { + fun( + payload["point"], + payload["latLng"], + id, + annotation, + hoverEventType, + ); + } + }); + + _maplibrePlatform.onCameraMoveStartedPlatform.add((_) { + _isCameraMoving = true; + if (!isDisposed) notifyListeners(); + }); + + _maplibrePlatform.onCameraMovePlatform.add((cameraPosition) { + _cameraPosition = cameraPosition; + onCameraMove?.call(cameraPosition); + if (!isDisposed) notifyListeners(); + }); + + _maplibrePlatform.onCameraIdlePlatform.add((cameraPosition) { + _isCameraMoving = false; + if (cameraPosition != null) { + _cameraPosition = cameraPosition; + } + onCameraIdle?.call(); + if (!isDisposed) notifyListeners(); + }); + + _maplibrePlatform.onMapStyleLoadedPlatform.add((_) async { + final interactionEnabled = annotationConsumeTapEvents.toSet(); + for (final type in annotationOrder.toSet()) { + final enableInteraction = interactionEnabled.contains(type); + switch (type) { + case AnnotationType.fill: + fillManager = FillManager( + this, + enableInteraction: enableInteraction, + ); + await fillManager!.initialize(); + case AnnotationType.line: + lineManager = LineManager( + this, + enableInteraction: enableInteraction, + ); + await lineManager!.initialize(); + case AnnotationType.circle: + circleManager = CircleManager( + this, + enableInteraction: enableInteraction, + ); + await circleManager!.initialize(); + case AnnotationType.symbol: + symbolManager = SymbolManager( + this, + enableInteraction: enableInteraction, + ); + await symbolManager!.initialize(); + } + } + onStyleLoadedCallback?.call(); + }); + + _maplibrePlatform.onMapClickPlatform.add((dict) { + onMapClick?.call(dict['point'], dict['latLng']); + }); + + _maplibrePlatform.onMapLongClickPlatform.add((dict) { + onMapLongClick?.call(dict['point'], dict['latLng']); + }); + + _maplibrePlatform.onCameraTrackingChangedPlatform.add((mode) { + onCameraTrackingChanged?.call(mode); + }); + + _maplibrePlatform.onCameraTrackingDismissedPlatform.add((_) { + onCameraTrackingDismissed?.call(); + }); + + _maplibrePlatform.onMapIdlePlatform.add((_) { + onMapIdle?.call(); + }); + _maplibrePlatform.onUserLocationUpdatedPlatform.add((location) { + onUserLocationUpdated?.call(location); + }); + } + + Annotation? getAnnotationById(dynamic id) { + if (id == null) return null; + + final formattedId = id.toString(); + return fillManager?.byId(formattedId) ?? + lineManager?.byId(formattedId) ?? + symbolManager?.byId(formattedId) ?? + circleManager?.byId(formattedId); + } + + FillManager? fillManager; + LineManager? lineManager; + CircleManager? circleManager; + SymbolManager? symbolManager; + + final OnStyleLoadedCallback? onStyleLoadedCallback; + final OnMapClickCallback? onMapClick; + final OnMapLongClickCallback? onMapLongClick; + + final OnUserLocationUpdated? onUserLocationUpdated; + + final OnCameraTrackingDismissedCallback? onCameraTrackingDismissed; + final OnCameraTrackingChangedCallback? onCameraTrackingChanged; + + final OnCameraMoveCallback? onCameraMove; + final OnCameraIdleCallback? onCameraIdle; + + final OnMapIdleCallback? onMapIdle; + + /// Callbacks to receive tap events for symbols placed on this map. + final ArgumentCallbacks onSymbolTapped = ArgumentCallbacks(); + + /// Callbacks to receive tap events for circles placed on this map. + final ArgumentCallbacks onCircleTapped = ArgumentCallbacks(); + + /// Callbacks to receive tap events for fills placed on this map. + final ArgumentCallbacks onFillTapped = ArgumentCallbacks(); + + /// Callbacks to receive tap events for lines placed on this map. + final ArgumentCallbacks onLineTapped = ArgumentCallbacks(); + + /// Callbacks to receive tap events for features (geojson layer) placed on this map. + final onFeatureTapped = []; + + /// Callbacks to receive drag events for features (geojson layer) placed on this map. + final onFeatureDrag = []; + + /// Callbacks to receive mouse events(enter,move,leave) on web for features (geojson layer) placed on this map. + final onFeatureHover = []; + + /// Callbacks to receive tap events for info windows on symbols + @Deprecated("InfoWindow tapped is no longer supported") + final ArgumentCallbacks onInfoWindowTapped = + ArgumentCallbacks(); + + /// The current set of symbols on this map added with the [addSymbol] or [addSymbols] methods. + /// + /// The returned set will be a detached snapshot of the symbols collection. + Set get symbols => symbolManager?.annotations ?? {}; + + /// The current set of lines on this map added with the [addLine] or [addLines] methods. + /// + /// The returned set will be a detached snapshot of the lines collection. + Set get lines => lineManager?.annotations ?? {}; + + /// The current set of circles on this map added with the [addCircle] or [addCircles] methods. + /// + /// The returned set will be a detached snapshot of the circles collection. + Set get circles => circleManager?.annotations ?? {}; + + /// The current set of fills on this map added with the [addFill] or [addFills] methods. + /// + /// The returned set will be a detached snapshot of the fills collection. + Set get fills => fillManager?.annotations ?? {}; + + /// True if the map camera is currently moving. + bool get isCameraMoving => _isCameraMoving; + bool _isCameraMoving = false; + + /// Returns the most recent camera position reported by the platform side. + /// Will be null, if [MapLibreMap.trackCameraPosition] is false. + CameraPosition? get cameraPosition => _cameraPosition; + CameraPosition? _cameraPosition; + + final MapLibrePlatform _maplibrePlatform; + + /// Tracks whether the controller has already been disposed + bool _isDisposed = false; + + /// Return whether the controller has already been disposed. + bool get isDisposed => _isDisposed; + + /// Updates configuration options of the map user interface. + /// + /// Change listeners are notified once the update has been made on the + /// platform side. + /// + /// The returned [Future] completes after listeners have been notified. + Future _updateMapOptions(Map optionsUpdate) async { + _cameraPosition = await _maplibrePlatform.updateMapOptions(optionsUpdate); + if (!isDisposed) notifyListeners(); + } + + /// Triggers a resize event for the map on web (ignored on Android or iOS). + /// + /// Checks first if a resize is required or if it looks like it is already correctly resized. + /// If it looks good, the resize call will be skipped. + /// + /// To force resize map (without any checks) have a look at forceResizeWebMap() + void resizeWebMap() { + _maplibrePlatform.resizeWebMap(); + } + + /// Triggers a hard map resize event on web and does not check if it is required or not. + void forceResizeWebMap() { + _maplibrePlatform.forceResizeWebMap(); + } + + /// Starts an animated change of the map camera position. + /// + /// [duration] is the amount of time, that the transition animation should take. + /// + /// The returned [Future] completes after the change has been started on the + /// platform side. + /// It returns true if the camera was successfully moved and false if the movement was canceled. + /// Note: this currently always returns immediately with a value of null on iOS + Future animateCamera(CameraUpdate cameraUpdate, + {Duration? duration}) async { + return _maplibrePlatform.animateCamera(cameraUpdate, duration: duration); + } + + /// Instantaneously re-position the camera. + /// Note: moveCamera() quickly moves the camera, which can be visually jarring for a user. Strongly consider using the animateCamera() methods instead because it's less abrupt. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// It returns true if the camera was successfully moved and false if the movement was canceled. + /// Note: this currently always returns immediately with a value of null on iOS + Future moveCamera(CameraUpdate cameraUpdate) async { + return _maplibrePlatform.moveCamera(cameraUpdate); + } + + /// Adds a new geojson source + /// + /// The json in [geojson] has to comply with the schema for FeatureCollection + /// as specified in https://datatracker.ietf.org/doc/html/rfc7946#section-3.3 + /// + /// [promoteId] can be used on web to promote an id from properties to be the + /// id of the feature. This is useful because by default maplibre-gl-js does not + /// support string ids + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future addGeoJsonSource(String sourceId, Map geojson, + {String? promoteId}) async { + await _maplibrePlatform.addGeoJsonSource(sourceId, geojson, + promoteId: promoteId); + } + + /// Sets new geojson data to and existing source + /// + /// This only works as exected if the source has been created with + /// [addGeoJsonSource] before. This is very useful if you want to update and + /// existing source with modified data. + /// + /// The json in [geojson] has to comply with the schema for FeatureCollection + /// as specified in https://datatracker.ietf.org/doc/html/rfc7946#section-3.3 + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future setGeoJsonSource( + String sourceId, Map geojson) async { + await _maplibrePlatform.setGeoJsonSource(sourceId, geojson); + } + + /// Sets new geojson data to and existing source + /// + /// This only works as exected if the source has been created with + /// [addGeoJsonSource] before. This is very useful if you want to update and + /// existing source with modified data. + /// + /// The json in [geojson] has to comply with the schema for FeatureCollection + /// as specified in https://datatracker.ietf.org/doc/html/rfc7946#section-3.3 + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future setGeoJsonFeature( + String sourceId, Map geojsonFeature) async { + await _maplibrePlatform.setFeatureForGeoJsonSource( + sourceId, geojsonFeature); + } + + /// Add a symbol layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// If [enableInteraction] is set the layer is considered for touch or drag + /// events. [sourceLayer] is used to selected a specific source layer from + /// Vector source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + /// [filter] determines which features should be rendered in the layer. + /// Filters are written as [expressions]. + /// + /// [expressions]: https://maplibre.org/maplibre-style-spec/expressions/ + Future addSymbolLayer( + String sourceId, String layerId, SymbolLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + bool enableInteraction = true}) async { + await _maplibrePlatform.addSymbolLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + ); + } + + /// Add a line layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// If [enableInteraction] is set the layer is considered for touch or drag + /// events. [sourceLayer] is used to selected a specific source layer from + /// Vector source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + /// [filter] determines which features should be rendered in the layer. + /// Filters are written as [expressions]. + /// + /// [expressions]: https://maplibre.org/maplibre-style-spec/expressions/ + Future addLineLayer( + String sourceId, String layerId, LineLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + bool enableInteraction = true}) async { + await _maplibrePlatform.addLineLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + ); + } + + /// Set one or multiple properties of a layer. + /// You can only use properties that are supported for the layer's type. + /// So you can e.g. only use LineLayerProperties on a line layer. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// NOTE: The [properties] will not skip null values, so setting a property to null will potentially reset it to default. + Future setLayerProperties( + String layerId, + LayerProperties properties, + ) async { + await _maplibrePlatform.setLayerProperties( + layerId, + properties.toJson(skipNulls: false), + ); + } + + /// Add a fill layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// If [enableInteraction] is set the layer is considered for touch or drag + /// events. [sourceLayer] is used to selected a specific source layer from + /// Vector source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + /// [filter] determines which features should be rendered in the layer. + /// Filters are written as [expressions]. + /// + /// [expressions]: https://maplibre.org/maplibre-style-spec/expressions/ + Future addFillLayer( + String sourceId, String layerId, FillLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + bool enableInteraction = true}) async { + await _maplibrePlatform.addFillLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + ); + } + + /// Add a fill extrusion layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// If [enableInteraction] is set the layer is considered for touch or drag + /// events. [sourceLayer] is used to selected a specific source layer from + /// Vector source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + /// [filter] determines which features should be rendered in the layer. + /// Filters are written as [expressions]. + /// + /// [expressions]: https://maplibre.org/maplibre-style-spec/expressions/ + Future addFillExtrusionLayer( + String sourceId, String layerId, FillExtrusionLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + bool enableInteraction = true}) async { + await _maplibrePlatform.addFillExtrusionLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + ); + } + + /// Add a circle layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// If [enableInteraction] is set the layer is considered for touch or drag + /// events. [sourceLayer] is used to selected a specific source layer from + /// Vector source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + /// [filter] determines which features should be rendered in the layer. + /// Filters are written as [expressions]. + /// + /// [expressions]: https://maplibre.org/maplibre-style-spec/expressions/ + Future addCircleLayer( + String sourceId, String layerId, CircleLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter, + bool enableInteraction = true}) async { + await _maplibrePlatform.addCircleLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter, + enableInteraction: enableInteraction, + ); + } + + /// Add a raster layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// [sourceLayer] is used to selected a specific source layer from + /// Raster source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + Future addRasterLayer( + String sourceId, String layerId, RasterLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom}) async { + await _maplibrePlatform.addRasterLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + ); + } + + /// Add a hillshade layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// [sourceLayer] is used to selected a specific source layer from + /// Raster source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + Future addHillshadeLayer( + String sourceId, String layerId, HillshadeLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom}) async { + await _maplibrePlatform.addHillshadeLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + ); + } + + /// Add a heatmap layer to the map with the given properties + /// + /// Consider using [addLayer] for an unified layer api. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// [sourceLayer] is used to selected a specific source layer from + /// Raster source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + Future addHeatmapLayer( + String sourceId, String layerId, HeatmapLayerProperties properties, + {String? belowLayerId, + String? sourceLayer, + double? minzoom, + double? maxzoom}) async { + await _maplibrePlatform.addHeatmapLayer( + sourceId, + layerId, + properties.toJson(), + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + ); + } + + /// Updates user location tracking mode. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future updateMyLocationTrackingMode( + MyLocationTrackingMode myLocationTrackingMode) async { + return _maplibrePlatform + .updateMyLocationTrackingMode(myLocationTrackingMode); + } + + /// Updates the language of the map labels to match the device's language. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future matchMapLanguageWithDeviceDefault() async { + return _maplibrePlatform.matchMapLanguageWithDeviceDefault(); + } + + /// Updates the distance from the edges of the map view’s frame to the edges + /// of the map view’s logical viewport, optionally animating the change. + /// + /// When the value of this property is equal to `EdgeInsets.zero`, viewport + /// properties such as centerCoordinate assume a viewport that matches the map + /// view’s frame. Otherwise, those properties are inset, excluding part of the + /// frame from the viewport. For instance, if the only the top edge is inset, + /// the map center is effectively shifted downward. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future updateContentInsets(EdgeInsets insets, + [bool animated = false]) async { + return _maplibrePlatform.updateContentInsets(insets, animated); + } + + /// Updates the language of the map labels to match the specified language. + /// This will use labels with "name:$language" if available, otherwise "name:latin" or "name". + /// This naming schema is used by OpenStreetMap (see [https://wiki.openstreetmap.org/wiki/Multilingual_names]), + /// and is also used by some other vector tile generation software and vector tile providers. + /// Commonly, (and according to the OSM wiki) [language] should be + /// "a lowercase language's ISO 639-1 alpha2 code (second column), a lowercase ISO 639-2 code if an ISO 639-1 code doesn't exist, or a ISO 639-3 code if neither of those exist". + /// + /// If your vector tiles do not follow this schema of having labels with "name:$language" for different language, this method will not work for you. + /// In that case, you need to adapt your MapLibre style accordingly yourself to use labels in your preferred language. + /// + /// Attention: This may only be called after onStyleLoaded() has been invoked. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future setMapLanguage(String language) async { + return _maplibrePlatform.setMapLanguage(language); + } + + /// Enables or disables the collection of anonymized telemetry data. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future setTelemetryEnabled(bool enabled) async { + return _maplibrePlatform.setTelemetryEnabled(enabled); + } + + /// Retrieves whether collection of anonymized telemetry data is enabled. + /// + /// The returned [Future] completes after the query has been made on the + /// platform side. + Future getTelemetryEnabled() async { + return _maplibrePlatform.getTelemetryEnabled(); + } + + /// Sets the maximum frames per second for the map rendering. + /// + /// This can help optimize performance on lower-end devices by limiting + /// the rendering frequency. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future setMaximumFps(int fps) async { + return _maplibrePlatform.setMaximumFps(fps); + } + + /// Forces the map to use online mode, disabling any offline functionality. + /// + /// This is useful for testing or when you want to ensure the map always + /// uses the latest data from the network. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + Future forceOnlineMode() async { + return _maplibrePlatform.forceOnlineMode(); + } + + /// Eases the camera to a new position with an optional duration. + /// + /// The [cameraUpdate] specifies the target camera position, and [duration] + /// specifies the animation duration in milliseconds (optional). + /// + /// The returned [Future] completes with true if the animation finished successfully, + /// or false if it was cancelled. + Future easeCamera(CameraUpdate cameraUpdate, + {Duration? duration}) async { + return _maplibrePlatform.easeCamera(cameraUpdate, duration: duration); + } + + /// Queries the current camera position. + /// + /// Returns the current camera position including center, zoom, bearing, and tilt. + /// Returns null if the camera position cannot be determined. + /// + /// The returned [Future] completes with the current camera position. + Future queryCameraPosition() async { + return _maplibrePlatform.queryCameraPosition(); + } + + /// Edits a GeoJSON source with new data. + /// + /// The [id] specifies the source identifier, and [data] contains the new + /// GeoJSON data as a string. + /// + /// The returned [Future] completes with true if the source was successfully + /// updated, false otherwise. + Future editGeoJsonSource(String id, String data) async { + return _maplibrePlatform.editGeoJsonSource(id, data); + } + + /// Edits a GeoJSON source with a new URL. + /// + /// The [id] specifies the source identifier, and [url] contains the new + /// URL for the GeoJSON data. + /// + /// The returned [Future] completes with true if the source was successfully + /// updated, false otherwise. + Future editGeoJsonUrl(String id, String url) async { + return _maplibrePlatform.editGeoJsonUrl(id, url); + } + + /// Sets a filter for a layer. + /// + /// The [layerId] specifies the layer identifier, and [filter] contains the + /// filter expression as a JSON string. + /// + /// The returned [Future] completes with true if the filter was successfully + /// applied, false otherwise. + Future setLayerFilter(String layerId, String filter) async { + return _maplibrePlatform.setLayerFilter(layerId, filter); + } + + /// Gets the current map style as JSON string. + /// + /// The returned [Future] completes with the style JSON string if successful, + /// null otherwise. + Future getStyle() async { + return _maplibrePlatform.getStyle(); + } + + /// Sets custom HTTP headers for map requests. + /// + /// The [headers] map contains the header key-value pairs to set, and [filter] + /// contains URL patterns to determine which requests should include these headers. + /// + /// The returned [Future] completes when the headers are successfully set. + Future setCustomHeaders( + Map headers, List filter) async { + return _maplibrePlatform.setCustomHeaders(headers, filter); + } + + /// Gets the current custom HTTP headers. + /// + /// The returned [Future] completes with a map of the current custom headers. + Future> getCustomHeaders() async { + return _maplibrePlatform.getCustomHeaders(); + } + + /// Adds a symbol to the map, configured using the specified custom [options]. + /// + /// Change listeners are notified once the symbol has been added on the + /// platform side. + /// + /// The returned [Future] completes with the added symbol once listeners have + /// been notified.\ + /// An [Exception] is thrown if the SymbolManager is not initialized (style not loaded yet). + Future addSymbol(SymbolOptions options, [Map? data]) async { + _ensureManagerInitialized(symbolManager); + + final effectiveOptions = SymbolOptions.defaultOptions.copyWith(options); + final symbol = Symbol(getRandomString(), effectiveOptions, data); + await symbolManager?.add(symbol); + if (!isDisposed) notifyListeners(); + return symbol; + } + + /// Adds multiple symbols to the map, configured using the specified custom + /// [options]. + /// + /// Change listeners are notified once the symbol has been added on the + /// platform side. + /// + /// The returned [Future] completes with the added symbol once listeners have + /// been notified.\ + /// An [Exception] is thrown if the SymbolManager is not initialized (style not loaded yet). + Future> addSymbols( + List options, [ + List? data, + ]) async { + _ensureManagerInitialized(symbolManager); + + final symbols = [ + for (var i = 0; i < options.length; i++) + Symbol( + getRandomString(), + SymbolOptions.defaultOptions.copyWith(options[i]), + data?[i], + ) + ]; + await symbolManager?.addAll(symbols); + if (!isDisposed) notifyListeners(); + return symbols; + } + + /// Updates the specified [symbol] with the given [changes]. The symbol must + /// be a current member of the [symbols] set. + /// + /// Change listeners are notified once the symbol has been updated on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified.\ + /// An [Exception] is thrown if the SymbolManager is not initialized (style not loaded yet). + Future updateSymbol(Symbol symbol, SymbolOptions changes) async { + await symbolManager?.set( + symbol..options = symbol.options.copyWith(changes), + ); + if (!isDisposed) notifyListeners(); + } + + /// Retrieves the current position of the symbol. + /// This may be different from the value of `symbol.options.geometry` if the symbol is draggable. + /// In that case this method provides the symbol's actual position, and `symbol.options.geometry` the last programmatically set position.\ + /// An [Exception] is thrown if the Symbol has no geometry set. + LatLng getSymbolLatLng(Symbol symbol) { + if (symbol.options.geometry == null) { + throw ArgumentError( + "Symbol geometry is null. Cannot determine position.", + ); + } + + return symbol.options.geometry!; + } + + /// Removes the specified [symbol] from the map. The symbol must be a current + /// member of the [symbols] set. + /// + /// Change listeners are notified once the symbol has been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeSymbol(Symbol symbol) async { + await symbolManager?.remove(symbol); + if (!isDisposed) notifyListeners(); + } + + /// Removes the specified [symbols] from the map. The symbols must be current + /// members of the [symbols] set. + /// + /// Change listeners are notified once the symbol has been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeSymbols(Iterable symbols) async { + await symbolManager?.removeAll(symbols); + if (!isDisposed) notifyListeners(); + } + + /// Removes all [symbols] from the map added with the [addSymbol] or [addSymbols] methods. + /// + /// Change listeners are notified once all symbols have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future clearSymbols() async { + await symbolManager?.clear(); + if (!isDisposed) notifyListeners(); + } + + /// Adds a line to the map, configured using the specified custom [options]. + /// + /// Change listeners are notified once the line has been added on the + /// platform side. + /// + /// The returned [Future] completes with the added line once listeners have + /// been notified.\ + /// An [Exception] is thrown if the LineManager is not initialized (style not loaded yet). + Future addLine(LineOptions options, [Map? data]) async { + _ensureManagerInitialized(lineManager); + + final effectiveOptions = LineOptions.defaultOptions.copyWith(options); + final line = Line(getRandomString(), effectiveOptions, data); + await lineManager?.add(line); + if (!isDisposed) notifyListeners(); + return line; + } + + /// Adds multiple lines to the map, configured using the specified custom [options]. + /// + /// Change listeners are notified once the lines have been added on the + /// platform side. + /// + /// The returned [Future] completes with the added line once listeners have + /// been notified.\ + /// An [Exception] is thrown if the LineManager is not initialized (style not loaded yet). + Future> addLines( + List options, [ + List? data, + ]) async { + _ensureManagerInitialized(lineManager); + + final lines = [ + for (var i = 0; i < options.length; i++) + Line( + getRandomString(), + LineOptions.defaultOptions.copyWith(options[i]), + data?[i], + ) + ]; + await lineManager?.addAll(lines); + if (!isDisposed) notifyListeners(); + return lines; + } + + /// Updates the specified [line] with the given [changes]. The line must + /// be a current member of the [lines] set.‚ + /// + /// Change listeners are notified once the line has been updated on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future updateLine(Line line, LineOptions changes) async { + line.options = line.options.copyWith(changes); + await lineManager?.set(line); + if (!isDisposed) notifyListeners(); + } + + /// Retrieves the current position of the line. + /// This may be different from the value of `line.options.geometry` if the line is draggable. + /// In that case this method provides the line's actual position, and `line.options.geometry` the last programmatically set position. + /// An [Exception] is thrown if the Line has no geometry set. + List getLineLatLngs(Line line) { + if (line.options.geometry == null) { + throw ArgumentError( + "Line geometry is null. Cannot determine position.", + ); + } + + return line.options.geometry!; + } + + /// Removes the specified [line] from the map. The line must be a current + /// member of the [lines] set. + /// + /// Change listeners are notified once the line has been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeLine(Line line) async { + await lineManager?.remove(line); + if (!isDisposed) notifyListeners(); + } + + /// Removes the specified [lines] from the map. The lines must be current + /// members of the [lines] set. + /// + /// Change listeners are notified once the lines have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeLines(Iterable lines) async { + await lineManager?.removeAll(lines); + if (!isDisposed) notifyListeners(); + } + + /// Removes all [lines] from the map added with the [addLine] or [addLines] methods. + /// + /// Change listeners are notified once all lines have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future clearLines() async { + await lineManager?.clear(); + if (!isDisposed) notifyListeners(); + } + + /// Adds a circle to the map, configured using the specified custom [options]. + /// + /// Change listeners are notified once the circle has been added on the + /// platform side. + /// + /// The returned [Future] completes with the added circle once listeners have + /// been notified.\ + /// An [Exception] is thrown if the CircleManager is not initialized (style not loaded yet). + Future addCircle(CircleOptions options, [Map? data]) async { + _ensureManagerInitialized(circleManager); + + final effectiveOptions = CircleOptions.defaultOptions.copyWith(options); + final circle = Circle(getRandomString(), effectiveOptions, data); + await circleManager?.add(circle); + if (!isDisposed) notifyListeners(); + return circle; + } + + /// Adds multiple circles to the map, configured using the specified custom + /// [options]. + /// + /// Change listeners are notified once the circles have been added on the + /// platform side. + /// + /// The returned [Future] completes with the added circle once listeners have + /// been notified.\ + /// An [Exception] is thrown if the CircleManager is not initialized (style not loaded yet). + Future> addCircles( + List options, [ + List? data, + ]) async { + _ensureManagerInitialized(circleManager); + + final circles = [ + for (var i = 0; i < options.length; i++) + Circle( + getRandomString(), + CircleOptions.defaultOptions.copyWith(options[i]), + data?[i], + ) + ]; + await circleManager?.addAll(circles); + if (!isDisposed) notifyListeners(); + return circles; + } + + /// Updates the specified [circle] with the given [changes]. The circle must + /// be a current member of the [circles] set. + /// + /// Change listeners are notified once the circle has been updated on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future updateCircle(Circle circle, CircleOptions changes) async { + circle.options = circle.options.copyWith(changes); + await circleManager?.set(circle); + if (!isDisposed) notifyListeners(); + } + + /// Retrieves the current position of the circle. + /// This may be different from the value of `circle.options.geometry` if the circle is draggable. + /// In that case this method provides the circle's actual position, and `circle.options.geometry` the last programmatically set position.\ + /// An [Exception] is thrown if the Circle has no geometry set. + LatLng getCircleLatLng(Circle circle) { + if (circle.options.geometry == null) { + throw ArgumentError( + "Circle geometry is null. Cannot determine position.", + ); + } + + return circle.options.geometry!; + } + + /// Removes the specified [circle] from the map. The circle must be a current + /// member of the [circles] set. + /// + /// Change listeners are notified once the circle has been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeCircle(Circle circle) async { + await circleManager?.remove(circle); + if (!isDisposed) notifyListeners(); + } + + /// Removes the specified [circles] from the map. The circles must be current + /// members of the [circles] set. + /// + /// Change listeners are notified once the circles have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeCircles(Iterable circles) async { + await circleManager?.removeAll(circles); + if (!isDisposed) notifyListeners(); + } + + /// Removes all [circles] from the map added with the [addCircle] or [addCircles] methods. + /// + /// Change listeners are notified once all circles have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future clearCircles() async { + await circleManager?.clear(); + if (!isDisposed) notifyListeners(); + } + + /// Adds a fill to the map, configured using the specified custom [options]. + /// + /// Change listeners are notified once the fill has been added on the + /// platform side. + /// + /// The returned [Future] completes with the added fill once listeners have + /// been notified.\ + /// An [Exception] is thrown if the FillManager is not initialized (style not loaded yet). + Future addFill(FillOptions options, [Map? data]) async { + _ensureManagerInitialized(fillManager); + + final effectiveOptions = FillOptions.defaultOptions.copyWith(options); + final fill = Fill(getRandomString(), effectiveOptions, data); + await fillManager?.add(fill); + if (!isDisposed) notifyListeners(); + return fill; + } + + /// Adds multiple fills to the map, configured using the specified custom + /// [options]. + /// + /// Change listeners are notified once the fills has been added on the + /// platform side. + /// + /// The returned [Future] completes with the added fills once listeners have + /// been notified.\ + /// An [Exception] is thrown if the FillManager is not initialized (style not loaded yet). + Future> addFills( + List options, [ + List? data, + ]) async { + _ensureManagerInitialized(fillManager); + + final fills = [ + for (var i = 0; i < options.length; i++) + Fill( + getRandomString(), + FillOptions.defaultOptions.copyWith(options[i]), + data?[i], + ) + ]; + await fillManager?.addAll(fills); + if (!isDisposed) notifyListeners(); + return fills; + } + + /// Updates the specified [fill] with the given [changes]. The fill must + /// be a current member of the [fills] set. + /// + /// Change listeners are notified once the fill has been updated on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future updateFill(Fill fill, FillOptions changes) async { + fill.options = fill.options.copyWith(changes); + await fillManager?.set(fill); + if (!isDisposed) notifyListeners(); + } + + /// Removes all [fills] from the map added with the [addFill] or [addFills] methods. + /// + /// Change listeners are notified once all fills have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future clearFills() async { + await fillManager?.clear(); + if (!isDisposed) notifyListeners(); + } + + /// Removes the specified [fill] from the map. The fill must be a current + /// member of the [fills] set. + /// + /// Change listeners are notified once the fill has been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeFill(Fill fill) async { + await fillManager?.remove(fill); + if (!isDisposed) notifyListeners(); + } + + /// Removes the specified [fills] from the map. The fills must be current + /// members of the [fills] set. + /// + /// Change listeners are notified once the fills have been removed on the + /// platform side. + /// + /// The returned [Future] completes once listeners have been notified. + Future removeFills(Iterable fills) async { + await fillManager?.removeAll(fills); + if (!isDisposed) notifyListeners(); + } + + /// Retrieves the current position of the fill. + /// This may be different from the value of `fill.options.geometry` if the fill is + /// draggable. In that case this method provides the fill's actual position, + /// and `fill.options.geometry` the last programmatically set position.\ + /// An [Exception] is thrown if the Fill has no geometry set. + List> getFillLatLngs(Fill fill) { + if (fill.options.geometry == null) { + throw ArgumentError( + "Fill geometry is null. Cannot determine position.", + ); + } + + return fill.options.geometry!; + } + + /// Query rendered (i.e. visible) features at a point in screen coordinates + Future queryRenderedFeatures( + Point point, List layerIds, List? filter) async { + return _maplibrePlatform.queryRenderedFeatures(point, layerIds, filter); + } + + /// Query rendered (i.e. visible) features in a Rect in screen coordinates + Future queryRenderedFeaturesInRect( + Rect rect, List layerIds, String? filter) async { + return _maplibrePlatform.queryRenderedFeaturesInRect( + rect, layerIds, filter); + } + + /// Query features contained in the source with the specified [sourceId]. + /// + /// In contrast to [queryRenderedFeatures], this returns all features in the source, + /// regardless of whether they are currently rendered by the current style. + /// + /// Note: On web, this will probably only work for GeoJson source, not for vector tiles + Future querySourceFeatures( + String sourceId, String? sourceLayerId, List? filter) async { + return _maplibrePlatform.querySourceFeatures( + sourceId, sourceLayerId, filter); + } + + Future invalidateAmbientCache() async { + return _maplibrePlatform.invalidateAmbientCache(); + } + + Future clearAmbientCache() async { + return _maplibrePlatform.clearAmbientCache(); + } + + /// Get last my location + /// + /// Return last latlng, nullable + Future requestMyLocationLatLng() async { + return _maplibrePlatform.requestMyLocationLatLng(); + } + + /// This method returns the boundaries of the region currently displayed in the map. + Future getVisibleRegion() async { + return _maplibrePlatform.getVisibleRegion(); + } + + /// Adds an image to the style currently displayed in the map, so that it can later be referred to by the provided name. + /// + /// This allows you to add an image to the currently displayed style once, and from there on refer to it e.g. in the [Symbol.iconImage] anytime you add a [Symbol] later on. + /// Set [sdf] to true if the image you add is an SDF image. + /// Returns after the image has successfully been added to the style. + /// Note: This can only be called after OnStyleLoadedCallback has been invoked and any added images will have to be re-added if a new style is loaded. + /// + /// Example: Adding an asset image and using it in a new symbol: + /// ```dart + /// Future addImageFromAsset() async{ + /// final ByteData bytes = await rootBundle.load("assets/someAssetImage.jpg"); + /// final Uint8List list = bytes.buffer.asUint8List(); + /// await controller.addImage("assetImage", list); + /// controller.addSymbol( + /// SymbolOptions( + /// geometry: LatLng(0,0), + /// iconImage: "assetImage", + /// ), + /// ); + /// } + /// ``` + /// + /// Example: Adding a network image (with the http package) and using it in a new symbol: + /// ```dart + /// Future addImageFromUrl() async{ + /// var response = await get("https://example.com/image.png"); + /// await controller.addImage("testImage", response.bodyBytes); + /// controller.addSymbol( + /// SymbolOptions( + /// geometry: LatLng(0,0), + /// iconImage: "testImage", + /// ), + /// ); + /// } + /// ``` + Future addImage(String name, Uint8List bytes, [bool sdf = false]) { + return _maplibrePlatform.addImage(name, bytes, sdf); + } + + /// If true, the icon will be visible even if it collides with other previously drawn symbols. + Future setSymbolIconAllowOverlap(bool enable) async { + await symbolManager?.setIconAllowOverlap(enable); + } + + /// If true, other symbols can be visible even if they collide with the icon. + Future setSymbolIconIgnorePlacement(bool enable) async { + await symbolManager?.setIconIgnorePlacement(enable); + } + + /// If true, the text will be visible even if it collides with other previously drawn symbols. + Future setSymbolTextAllowOverlap(bool enable) async { + await symbolManager?.setTextAllowOverlap(enable); + } + + /// If true, other symbols can be visible even if they collide with the text. + Future setSymbolTextIgnorePlacement(bool enable) async { + await symbolManager?.setTextIgnorePlacement(enable); + } + + /// Adds an image source to the style currently displayed in the map, so that it can later be referred to by the provided id. + /// Not implemented on web. + Future addImageSource( + String imageSourceId, Uint8List bytes, LatLngQuad coordinates) { + return _maplibrePlatform.addImageSource(imageSourceId, bytes, coordinates); + } + + /// Update the image and/or coordinates of an image source. + /// Not implemented on web. + Future updateImageSource( + String imageSourceId, Uint8List? bytes, LatLngQuad? coordinates) { + return _maplibrePlatform.updateImageSource( + imageSourceId, bytes, coordinates); + } + + /// Removes previously added image source by id + @Deprecated("This method was renamed to removeSource") + Future removeImageSource(String imageSourceId) { + return _maplibrePlatform.removeSource(imageSourceId); + } + + /// Removes previously added source by id + Future removeSource(String sourceId) { + return _maplibrePlatform.removeSource(sourceId); + } + + /// Adds an image layer to the map's style at render time. + Future addImageLayer(String layerId, String imageSourceId, + {double? minzoom, double? maxzoom}) { + return _maplibrePlatform.addLayer(layerId, imageSourceId, minzoom, maxzoom); + } + + /// Adds an image layer below the layer provided with belowLayerId to the map's style at render time. + Future addImageLayerBelow( + String layerId, String sourceId, String imageSourceId, + {double? minzoom, double? maxzoom}) { + return _maplibrePlatform.addLayerBelow( + layerId, sourceId, imageSourceId, minzoom, maxzoom); + } + + /// Adds an image layer below the layer provided with belowLayerId to the map's style at render time. Only works for image sources! + @Deprecated("This method was renamed to addImageLayerBelow for clarity.") + Future addLayerBelow( + String layerId, String sourceId, String imageSourceId, + {double? minzoom, double? maxzoom}) { + return _maplibrePlatform.addLayerBelow( + layerId, sourceId, imageSourceId, minzoom, maxzoom); + } + + /// Removes a MapLibre style layer + Future removeLayer(String layerId) { + return _maplibrePlatform.removeLayer(layerId); + } + + Future setFilter(String layerId, dynamic filter) { + return _maplibrePlatform.setFilter(layerId, filter); + } + + Future getFilter(String layerId) { + return _maplibrePlatform.getFilter(layerId); + } + + /// Returns the point on the screen that corresponds to a geographical coordinate ([latLng]). The screen location is in screen pixels (not display pixels) relative to the top left of the map (not of the whole screen) + /// + /// Note: The resulting x and y coordinates are rounded to [int] on web, on other platforms they may differ very slightly (in the range of about 10^-10) from the actual nearest screen coordinate. + /// You therefore might want to round them appropriately, depending on your use case. + /// + /// Returns null if [latLng] is not currently visible on the map. + Future toScreenLocation(LatLng latLng) async { + return _maplibrePlatform.toScreenLocation(latLng); + } + + Future> toScreenLocationBatch(Iterable latLngs) async { + return _maplibrePlatform.toScreenLocationBatch(latLngs); + } + + /// Returns the geographic location (as [LatLng]) that corresponds to a point on the screen. The screen location is specified in screen pixels (not display pixels) relative to the top left of the map (not the top left of the whole screen). + Future toLatLng(Point screenLocation) async { + return _maplibrePlatform.toLatLng(screenLocation); + } + + /// Returns the distance spanned by one pixel at the specified [latitude] and current zoom level. + /// The distance between pixels decreases as the latitude approaches the poles. This relationship parallels the relationship between longitudinal coordinates at different latitudes. + Future getMetersPerPixelAtLatitude(double latitude) async { + return _maplibrePlatform.getMetersPerPixelAtLatitude(latitude); + } + + /// Add a new source to the map + Future addSource(String sourceid, SourceProperties properties) async { + return _maplibrePlatform.addSource(sourceid, properties); + } + + /// Pans and zooms the map to contain its visible area within the specified geographical bounds. + /// + /// Also consider using [animateCamera] or [moveCamera], which allow you to set camera bounds (with different padding values per side) + /// as well as other camera properties. + Future setCameraBounds({ + required double west, + required double north, + required double south, + required double east, + required int padding, + }) async { + return _maplibrePlatform.setCameraBounds( + west: west, + north: north, + south: south, + east: east, + padding: padding, + ); + } + + /// Add a layer to the map with the given properties + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. + /// + /// Setting [belowLayerId] adds the new layer below the given id. + /// If [enableInteraction] is set the layer is considered for touch or drag + /// events this has no effect for [RasterLayerProperties] and + /// [HillshadeLayerProperties]. + /// [sourceLayer] is used to selected a specific source layer from Vector + /// source. + /// [minzoom] is the minimum (inclusive) zoom level at which the layer is + /// visible. + /// [maxzoom] is the maximum (exclusive) zoom level at which the layer is + /// visible. + /// [filter] determines which features should be rendered in the layer. + /// Filters are written as [expressions]. + /// [filter] is not supported by RasterLayer and HillshadeLayer. + /// + /// [expressions]: https://maplibre.org/maplibre-style-spec/expressions/ + Future addLayer( + String sourceId, String layerId, LayerProperties properties, + {String? belowLayerId, + bool enableInteraction = true, + String? sourceLayer, + double? minzoom, + double? maxzoom, + dynamic filter}) async { + if (properties is FillLayerProperties) { + await addFillLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + enableInteraction: enableInteraction, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter); + } else if (properties is FillExtrusionLayerProperties) { + await addFillExtrusionLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom); + } else if (properties is LineLayerProperties) { + await addLineLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + enableInteraction: enableInteraction, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter); + } else if (properties is SymbolLayerProperties) { + await addSymbolLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + enableInteraction: enableInteraction, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter); + } else if (properties is CircleLayerProperties) { + await addCircleLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + enableInteraction: enableInteraction, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom, + filter: filter); + } else if (properties is RasterLayerProperties) { + if (filter != null) { + throw UnimplementedError("RasterLayer does not support filter"); + } + await addRasterLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom); + } else if (properties is HillshadeLayerProperties) { + if (filter != null) { + throw UnimplementedError("HillShadeLayer does not support filter"); + } + await addHillshadeLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom); + } else if (properties is HeatmapLayerProperties) { + await addHeatmapLayer(sourceId, layerId, properties, + belowLayerId: belowLayerId, + sourceLayer: sourceLayer, + minzoom: minzoom, + maxzoom: maxzoom); + } else { + throw UnimplementedError("Unknown layer type $properties"); + } + } + + Future setLayerVisibility(String layerId, bool visible) async { + return _maplibrePlatform.setLayerVisibility(layerId, visible); + } + + Future getLayerIds() { + return _maplibrePlatform.getLayerIds(); + } + + /// Retrieve every source ids of the map as a [String] list, including the ones added internally + /// + /// This method is not currently implemented on the web + Future> getSourceIds() async { + return (await _maplibrePlatform.getSourceIds()) + .whereType() + .toList(); + } + + /// Method to set style string + /// A MapLibre GL style document defining the map's appearance. + /// The style document specification is at [https://maplibre.org/maplibre-style-spec]. + /// A short introduction can be found in the documentation of the [maplibre_gl] library. + /// The [styleString] supports following formats: + /// + /// 1. Passing the URL of the map style. This should be a custom map style served remotely using a URL that start with 'http(s)://' + /// 2. Passing the style as a local asset. Create a JSON file in the `assets` and add a reference in `pubspec.yml`. Set the style string to the relative path for this asset in order to load it into the map. + /// 3. Passing the style as a local file. create an JSON file in app directory (e.g. ApplicationDocumentsDirectory). Set the style string to the absolute path of this JSON file. + /// 4. Passing the raw JSON of the map style. This is only supported on Android. + Future setStyle(String styleString) async { + return _maplibrePlatform.setStyle(styleString); + } + + @override + void dispose() { + _isDisposed = true; + super.dispose(); + _maplibrePlatform.dispose(); + } + + /// Ensures that the given manager is initialized. + /// If not, throws an [Exception]. + void _ensureManagerInitialized(AnnotationManager? manager) { + if (manager == null || !manager.isInitialized) { + throw Exception( + "This Annotation Manager has not been initialized. Make sure that the map style has been loaded.", + ); + } + } +} diff --git a/third_party/maplibre_gl/lib/src/download_region_status.dart b/third_party/maplibre_gl/lib/src/download_region_status.dart new file mode 100644 index 00000000..2f9aed2d --- /dev/null +++ b/third_party/maplibre_gl/lib/src/download_region_status.dart @@ -0,0 +1,25 @@ +part of '../maplibre_gl.dart'; + +abstract class DownloadRegionStatus {} + +class Success extends DownloadRegionStatus {} + +class InProgress extends DownloadRegionStatus { + final double progress; + + InProgress(this.progress); + + @override + String toString() => + "Instance of 'DownloadRegionStatus.InProgress', progress = $progress"; +} + +class Error extends DownloadRegionStatus { + final PlatformException cause; + + Error(this.cause); + + @override + String toString() => + "Instance of 'DownloadRegionStatus.Error', cause = $cause"; +} diff --git a/third_party/maplibre_gl/lib/src/global.dart b/third_party/maplibre_gl/lib/src/global.dart new file mode 100644 index 00000000..d9bfe46a --- /dev/null +++ b/third_party/maplibre_gl/lib/src/global.dart @@ -0,0 +1,136 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +part of '../maplibre_gl.dart'; + +const _globalChannel = MethodChannel('plugins.flutter.io/maplibre_gl'); + +/// Copy tiles db file passed in to the tiles cache directory (sideloaded) to +/// make tiles available offline. +Future installOfflineMapTiles(String tilesDb) async { + await _globalChannel.invokeMethod( + 'installOfflineMapTiles', + { + 'tilesdb': tilesDb, + }, + ); +} + +enum DragEventType { start, drag, end } + +enum HoverEventType { enter, move, leave } + +Future setOffline(bool offline) => _globalChannel.invokeMethod( + 'setOffline', + { + 'offline': offline, + }, + ); + +Future setHttpHeaders(Map headers) { + return _globalChannel.invokeMethod( + 'setHttpHeaders', + { + 'headers': headers, + }, + ); +} + +Future> mergeOfflineRegions(String path) async { + final String regionsJson = await _globalChannel.invokeMethod( + 'mergeOfflineRegions', + { + 'path': path, + }, + ); + final regions = List>.from(json.decode(regionsJson)); + return regions.map(OfflineRegion.fromMap).toList(); +} + +Future> getListOfRegions() async { + final String regionsJson = await _globalChannel.invokeMethod( + 'getListOfRegions', + {}, + ); + final regions = List>.from(json.decode(regionsJson)); + return regions.map(OfflineRegion.fromMap).toList(); +} + +Future updateOfflineRegionMetadata( + int id, Map metadata) async { + final regionJson = await _globalChannel.invokeMethod( + 'updateOfflineRegionMetadata', + { + 'id': id, + 'metadata': metadata, + }, + ); + + return OfflineRegion.fromMap(json.decode(regionJson)); +} + +Future setOfflineTileCountLimit(int limit) => + _globalChannel.invokeMethod( + 'setOfflineTileCountLimit', + { + 'limit': limit, + }, + ); + +Future deleteOfflineRegion(int id) => _globalChannel.invokeMethod( + 'deleteOfflineRegion', + { + 'id': id, + }, + ); + +Future downloadOfflineRegion( + OfflineRegionDefinition definition, { + Map metadata = const {}, + Function(DownloadRegionStatus event)? onEvent, +}) async { + final channelName = + 'downloadOfflineRegion_${DateTime.now().microsecondsSinceEpoch}'; + + await _globalChannel + .invokeMethod('downloadOfflineRegion#setup', { + 'channelName': channelName, + }); + + if (onEvent != null) { + EventChannel(channelName).receiveBroadcastStream().handleError((error) { + if (error is PlatformException) { + onEvent(Error(error)); + return Error(error); + } + final unknownError = Error( + PlatformException( + code: 'UnknowException', + message: + 'This error is unhandled by plugin. Please contact us if needed.', + details: error, + ), + ); + onEvent(unknownError); + return unknownError; + }).listen((data) { + final Map jsonData = json.decode(data); + final status = switch (jsonData['status']) { + 'start' => InProgress(0.0), + 'progress' => InProgress((jsonData['progress']! as num).toDouble()), + 'success' => Success(), + _ => throw Exception('Invalid event status ${jsonData['status']}'), + }; + onEvent(status); + }); + } + + final result = await _globalChannel + .invokeMethod('downloadOfflineRegion', { + 'definition': definition.toMap(), + 'metadata': metadata, + }); + + return OfflineRegion.fromMap(json.decode(result)); +} diff --git a/third_party/maplibre_gl/lib/src/layer_expressions.dart b/third_party/maplibre_gl/lib/src/layer_expressions.dart new file mode 100644 index 00000000..258df0d8 --- /dev/null +++ b/third_party/maplibre_gl/lib/src/layer_expressions.dart @@ -0,0 +1,657 @@ +// This file is generated by +// ./scripts/lib/generate.dart + +part of '../maplibre_gl.dart'; + +class Expressions { + /// Binds expressions to named variables, which can then be referenced in + /// the result expression using ["var", "variable_name"]. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const let = "let"; + + /// References variable bound using "let". + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const varExpression = "var"; + + /// Provides a literal array or object value. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const literal = "literal"; + + /// Asserts that the input is an array (optionally with a specific item + /// type and length). If, when the input expression is evaluated, it is + /// not of the asserted type, then this assertion will cause the whole + /// expression to be aborted. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const array = "array"; + + /// Retrieves an item from an array. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const at = "at"; + + /// Determines whether an item exists in an array or a substring exists in + /// a string. + /// + /// Sdk Support: + /// basic functionality with js + static const inExpression = "in"; + + /// Selects the first output whose corresponding test condition evaluates + /// to true, or the fallback value otherwise. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const caseExpression = "case"; + + /// Selects the output whose label value matches the input value, or the + /// fallback value if no match is found. The input can be any expression + /// (e.g. `["get", "building_type"]`). Each label must be either: + /// * a single literal value; or + /// * an array of literal values, whose values must be all strings or all + /// numbers (e.g. `[100, 101]` or `["c", "b"]`). The input matches if any + /// of the values in the array matches, similar to the `"in"` + /// operator.Each label must be unique. If the input type does not match + /// the type of the labels, the result will be the fallback value. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const match = "match"; + + /// Evaluates each expression in turn until the first non-null value is + /// obtained, and returns that value. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const coalesce = "coalesce"; + + /// Produces discrete, stepped results by evaluating a piecewise-constant + /// function defined by pairs of input and output values ("stops"). The + /// `input` may be any numeric expression (e.g., `["get", "population"]`). + /// Stop inputs must be numeric literals in strictly ascending order. + /// Returns the output value of the stop just less than the input, or the + /// first output if the input is less than the first stop. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const step = "step"; + + /// Produces continuous, smooth results by interpolating between pairs of + /// input and output values ("stops"). The `input` may be any numeric + /// expression (e.g., `["get", "population"]`). Stop inputs must be + /// numeric literals in strictly ascending order. The output type must be + /// `number`, `array`, or `color`.Interpolation types:- + /// `["linear"]`: interpolates linearly between the pair of stops just + /// less than and just greater than the input.- `["exponential", base]`: + /// interpolates exponentially between the stops just less than and just + /// greater than the input. `base` controls the rate at which the output + /// increases: higher values make the output increase more towards the + /// high end of the range. With values close to 1 the output increases + /// linearly.- `["cubic-bezier", x1, y1, x2, y2]`: interpolates using the + /// cubic bezier curve defined by the given control points. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const interpolate = "interpolate"; + + /// Produces continuous, smooth results by interpolating between pairs of + /// input and output values ("stops"). Works like `interpolate`, but the + /// output type must be `color`, and the interpolation is performed in the + /// Hue-Chroma-Luminance color space. + /// + /// Sdk Support: + /// basic functionality with js + static const interpolateHcl = "interpolate-hcl"; + + /// Produces continuous, smooth results by interpolating between pairs of + /// input and output values ("stops"). Works like `interpolate`, but the + /// output type must be `color`, and the interpolation is performed in the + /// CIELAB color space. + /// + /// Sdk Support: + /// basic functionality with js + static const interpolateLab = "interpolate-lab"; + + /// Returns mathematical constant ln(2). + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const ln2 = "ln2"; + + /// Returns the mathematical constant pi. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const pi = "pi"; + + /// Returns the mathematical constant e. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const e = "e"; + + /// Returns a string describing the type of the given value. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const typeof = "typeof"; + + /// Asserts that the input value is a string. If multiple values are + /// provided, each one is evaluated in order until a string is obtained. + /// If none of the inputs are strings, the expression is an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const string = "string"; + + /// Asserts that the input value is a number. If multiple values are + /// provided, each one is evaluated in order until a number is obtained. + /// If none of the inputs are numbers, the expression is an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const number = "number"; + + /// Asserts that the input value is a boolean. If multiple values are + /// provided, each one is evaluated in order until a boolean is obtained. + /// If none of the inputs are booleans, the expression is an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const boolean = "boolean"; + + /// Asserts that the input value is an object. If multiple values are + /// provided, each one is evaluated in order until an object is obtained. + /// If none of the inputs are objects, the expression is an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const object = "object"; + + /// Returns a `collator` for use in locale-dependent comparison + /// operations. The `case-sensitive` and `diacritic-sensitive` options + /// default to `false`. The `locale` argument specifies the IETF language + /// tag of the locale to use. If none is provided, the default locale is + /// used. If the requested locale is not available, the `collator` will + /// use a system-defined fallback locale. Use `resolved-locale` to test + /// the results of locale fallback behavior. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const collator = "collator"; + + /// Returns `formatted` text containing annotations for use in + /// mixed-format `text-field` entries. For a `text-field` entries of a + /// string type, following option object's properties are supported: If + /// set, the `text-font` value overrides the font specified by the root + /// layout properties. If set, the `font-scale` value specifies a scaling + /// factor relative to the `text-size` specified in the root layout + /// properties. If set, the `text-color` value overrides the color + /// specified by the root paint properties for this layer. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const format = "format"; + + /// Returns an `image` type for use in `icon-image`, `*-pattern` entries + /// and as a section in the `format` expression. If set, the `image` + /// argument will check that the requested image exists in the style and + /// will return either the resolved image name or `null`, depending on + /// whether or not the image is currently in the style. This validation + /// process is synchronous and requires the image to have been added to + /// the style before requesting it in the `image` argument. + /// + /// Sdk Support: + /// basic functionality with js, android, ios + static const image = "image"; + + /// Converts the input number into a string representation using the + /// providing formatting rules. If set, the `locale` argument specifies + /// the locale to use, as a BCP 47 language tag. If set, the `currency` + /// argument specifies an ISO 4217 code to use for currency-style + /// formatting. If set, the `min-fraction-digits` and + /// `max-fraction-digits` arguments specify the minimum and maximum number + /// of fractional digits to include. + /// + /// Sdk Support: + /// basic functionality with js + static const numberFormat = "number-format"; + + /// Converts the input value to a string. If the input is `null`, the + /// result is `""`. If the input is a boolean, the result is `"true"` or + /// `"false"`. If the input is a number, it is converted to a string as + /// specified by the ["NumberToString" + /// algorithm](https://tc39.github.io/ecma262/#sec-tostring-applied-to-the-number-type) + /// of the ECMAScript Language Specification. If the input is a color, it + /// is converted to a string of the form `"rgba(r,g,b,a)"`, where `r`, + /// `g`, and `b` are numerals ranging from 0 to 255, and `a` ranges from 0 + /// to 1. Otherwise, the input is converted to a string in the format + /// specified by the + /// [`JSON.stringify`](https://tc39.github.io/ecma262/#sec-json.stringify) + /// function of the ECMAScript Language Specification. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const toStringExpression = "to-string"; + + /// Converts the input value to a number, if possible. If the input is + /// `null` or `false`, the result is 0. If the input is `true`, the result + /// is 1. If the input is a string, it is converted to a number as + /// specified by the ["ToNumber Applied to the String Type" + /// algorithm](https://tc39.github.io/ecma262/#sec-tonumber-applied-to-the-string-type) + /// of the ECMAScript Language Specification. If multiple values are + /// provided, each one is evaluated in order until the first successful + /// conversion is obtained. If none of the inputs can be converted, the + /// expression is an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const toNumber = "to-number"; + + /// Converts the input value to a boolean. The result is `false` when then + /// input is an empty string, 0, `false`, `null`, or `NaN`; otherwise it + /// is `true`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const toBoolean = "to-boolean"; + + /// Returns a four-element array containing the input color's red, green, + /// blue, and alpha components, in that order. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const toRgba = "to-rgba"; + + /// Converts the input value to a color. If multiple values are provided, + /// each one is evaluated in order until the first successful conversion + /// is obtained. If none of the inputs can be converted, the expression is + /// an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const toColor = "to-color"; + + /// Creates a color value from red, green, and blue components, which must + /// range between 0 and 255, and an alpha component of 1. If any component + /// is out of range, the expression is an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const rgb = "rgb"; + + /// Creates a color value from red, green, blue components, which must + /// range between 0 and 255, and an alpha component which must range + /// between 0 and 1. If any component is out of range, the expression is + /// an error. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const rgba = "rgba"; + + /// Retrieves a property value from the current feature's properties, or + /// from another object if a second argument is provided. Returns null if + /// the requested property is missing. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const get = "get"; + + /// Tests for the presence of an property value in the current feature's + /// properties, or from another object if a second argument is provided. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const has = "has"; + + /// Gets the length of an array or string. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const length = "length"; + + /// Gets the feature properties object. Note that in some cases, it may + /// be more efficient to use ["get", "property_name"] directly. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const properties = "properties"; + + /// Retrieves a property value from the current feature's state. Returns + /// null if the requested property is not present on the feature's state. + /// A feature's state is not part of the GeoJSON or vector tile data, and + /// must be set programmatically on each feature. Features are identified + /// by their `id` attribute, which must be an integer or a string that can + /// be cast to an integer. Note that ["feature-state"] can only be used + /// with paint properties that support data-driven styling. + /// + /// Sdk Support: + /// basic functionality with js + static const featureState = "feature-state"; + + /// Gets the feature's geometry type: Point, MultiPoint, LineString, + /// MultiLineString, Polygon, MultiPolygon. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const geometryType = "geometry-type"; + + /// Gets the feature's id, if it has one. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const id = "id"; + + /// Gets the current zoom level. Note that in style layout and paint + /// properties, ["zoom"] may only appear as the input to a top-level + /// "step" or "interpolate" expression. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const zoom = "zoom"; + + /// Gets the kernel density estimation of a pixel in a heatmap layer, + /// which is a relative measure of how many data points are crowded around + /// a particular pixel. Can only be used in the `heatmap-color` property. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const heatmapDensity = "heatmap-density"; + + /// Gets the progress along a gradient line. Can only be used in the + /// `line-gradient` property. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const lineProgress = "line-progress"; + + /// Gets the value of a cluster property accumulated so far. Can only be + /// used in the `clusterProperties` option of a clustered GeoJSON source. + /// + /// Sdk Support: + /// basic functionality with js + static const accumulated = "accumulated"; + + /// Returns the sum of the inputs. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const plus = "+"; + + /// Returns the product of the inputs. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const multiply = "*"; + + /// For two inputs, returns the result of subtracting the second input + /// from the first. For a single input, returns the result of subtracting + /// it from 0. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const minus = "-"; + + /// Returns the result of floating point division of the first input by + /// the second. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const divide = "/"; + + /// Returns the remainder after integer division of the first input by the + /// second. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const precent = "%"; + + /// Returns the result of raising the first input to the power specified + /// by the second. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const xor = "^"; + + /// Returns the square root of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const sqrt = "sqrt"; + + /// Returns the base-ten logarithm of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const log10 = "log10"; + + /// Returns the natural logarithm of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const ln = "ln"; + + /// Returns the base-two logarithm of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const log2 = "log2"; + + /// Returns the sine of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const sin = "sin"; + + /// Returns the cosine of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const cos = "cos"; + + /// Returns the tangent of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const tan = "tan"; + + /// Returns the arcsine of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const asin = "asin"; + + /// Returns the arccosine of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const acos = "acos"; + + /// Returns the arctangent of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const atan = "atan"; + + /// Returns the minimum value of the inputs. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const min = "min"; + + /// Returns the maximum value of the inputs. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const max = "max"; + + /// Rounds the input to the nearest integer. Halfway values are rounded + /// away from zero. For example, `["round", -1.5]` evaluates to -2. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const round = "round"; + + /// Returns the absolute value of the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const abs = "abs"; + + /// Returns the smallest integer that is greater than or equal to the + /// input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const ceil = "ceil"; + + /// Returns the largest integer that is less than or equal to the input. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const floor = "floor"; + + /// Returns `true` if the input values are equal, `false` otherwise. The + /// comparison is strictly typed: values of different runtime types are + /// always considered unequal. Cases where the types are known to be + /// different at parse time are considered invalid and will produce a + /// parse error. Accepts an optional `collator` argument to control + /// locale-dependent string comparisons. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const equal = "=="; + + /// Returns `true` if the input values are not equal, `false` otherwise. + /// The comparison is strictly typed: values of different runtime types + /// are always considered unequal. Cases where the types are known to be + /// different at parse time are considered invalid and will produce a + /// parse error. Accepts an optional `collator` argument to control + /// locale-dependent string comparisons. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const notEqual = "!="; + + /// Returns `true` if the first input is strictly greater than the second, + /// `false` otherwise. The arguments are required to be either both + /// strings or both numbers; if during evaluation they are not, expression + /// evaluation produces an error. Cases where this constraint is known not + /// to hold at parse time are considered in valid and will produce a parse + /// error. Accepts an optional `collator` argument to control + /// locale-dependent string comparisons. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const larger = ">"; + + /// Returns `true` if the first input is strictly less than the second, + /// `false` otherwise. The arguments are required to be either both + /// strings or both numbers; if during evaluation they are not, expression + /// evaluation produces an error. Cases where this constraint is known not + /// to hold at parse time are considered in valid and will produce a parse + /// error. Accepts an optional `collator` argument to control + /// locale-dependent string comparisons. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const smaller = "<"; + + /// Returns `true` if the first input is greater than or equal to the + /// second, `false` otherwise. The arguments are required to be either + /// both strings or both numbers; if during evaluation they are not, + /// expression evaluation produces an error. Cases where this constraint + /// is known not to hold at parse time are considered in valid and will + /// produce a parse error. Accepts an optional `collator` argument to + /// control locale-dependent string comparisons. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const largerOrEqual = ">="; + + /// Returns `true` if the first input is less than or equal to the second, + /// `false` otherwise. The arguments are required to be either both + /// strings or both numbers; if during evaluation they are not, expression + /// evaluation produces an error. Cases where this constraint is known not + /// to hold at parse time are considered in valid and will produce a parse + /// error. Accepts an optional `collator` argument to control + /// locale-dependent string comparisons. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const smallerOrEqual = "<="; + + /// Returns `true` if all the inputs are `true`, `false` otherwise. The + /// inputs are evaluated in order, and evaluation is short-circuiting: + /// once an input expression evaluates to `false`, the result is `false` + /// and no further input expressions are evaluated. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const all = "all"; + + /// Returns `true` if any of the inputs are `true`, `false` otherwise. The + /// inputs are evaluated in order, and evaluation is short-circuiting: + /// once an input expression evaluates to `true`, the result is `true` and + /// no further input expressions are evaluated. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const any = "any"; + + /// Logical negation. Returns `true` if the input is `false`, and `false` + /// if the input is `true`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const not = "!"; + + /// Returns `true` if the input string is expected to render legibly. + /// Returns `false` if the input string contains sections that cannot be + /// rendered without potential loss of meaning (e.g. Indic scripts that + /// require complex text shaping, or right-to-left scripts if the the + /// `mapbox-gl-rtl-text` plugin is not in use in MapLibre GL JS). + /// + /// Sdk Support: + /// basic functionality with js, android + static const isSupportedScript = "is-supported-script"; + + /// Returns the input string converted to uppercase. Follows the Unicode + /// Default Case Conversion algorithm and the locale-insensitive case + /// mappings in the Unicode Character Database. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const upcase = "upcase"; + + /// Returns the input string converted to lowercase. Follows the Unicode + /// Default Case Conversion algorithm and the locale-insensitive case + /// mappings in the Unicode Character Database. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const downcase = "downcase"; + + /// Returns a `string` consisting of the concatenation of the inputs. Each + /// input is converted to a string as if by `to-string`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const concat = "concat"; + + /// Returns the IETF language tag of the locale being used by the provided + /// `collator`. This can be used to determine the default system locale, + /// or to determine if a requested locale was successfully loaded. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + static const resolvedLocale = "resolved-locale"; +} diff --git a/third_party/maplibre_gl/lib/src/layer_properties.dart b/third_party/maplibre_gl/lib/src/layer_properties.dart new file mode 100644 index 00000000..7e0e38d2 --- /dev/null +++ b/third_party/maplibre_gl/lib/src/layer_properties.dart @@ -0,0 +1,2380 @@ +// This file is generated by +// ./scripts/lib/generate.dart + +part of '../maplibre_gl.dart'; + +abstract class LayerProperties { + Map toJson({bool skipNulls = true}); +} + +class SymbolLayerProperties implements LayerProperties { + // Paint Properties + /// The opacity at which the icon will be drawn. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconOpacity; + + /// The color of the icon. This can only be used with sdf icons. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconColor; + + /// The color of the icon's halo. Icon halos can only be used with SDF + /// icons. + /// + /// Type: color + /// default: rgba(0, 0, 0, 0) + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconHaloColor; + + /// Distance of halo to the icon outline. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconHaloWidth; + + /// Fade out the halo towards the outside. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconHaloBlur; + + /// Distance that the icon's anchor is moved from its original placement. + /// Positive values indicate right and down, while negative values + /// indicate left and up. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconTranslate; + + /// Controls the frame of reference for `icon-translate`. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// Icons are translated relative to the map. + /// "viewport" + /// Icons are translated relative to the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconTranslateAnchor; + + /// The opacity at which the text will be drawn. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textOpacity; + + /// The color with which the text will be drawn. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textColor; + + /// The color of the text's halo, which helps it stand out from + /// backgrounds. + /// + /// Type: color + /// default: rgba(0, 0, 0, 0) + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textHaloColor; + + /// Distance of halo to the font outline. Max text halo width is 1/4 of + /// the font-size. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textHaloWidth; + + /// The halo's fadeout distance towards the outside. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textHaloBlur; + + /// Distance that the text's anchor is moved from its original placement. + /// Positive values indicate right and down, while negative values + /// indicate left and up. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textTranslate; + + /// Controls the frame of reference for `text-translate`. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// The text is translated relative to the map. + /// "viewport" + /// The text is translated relative to the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textTranslateAnchor; + + // Layout Properties + /// Label placement relative to its geometry. + /// + /// Type: enum + /// default: point + /// Options: + /// "point" + /// The label is placed at the point where the geometry is located. + /// "line" + /// The label is placed along the line of the geometry. Can only be + /// used on `LineString` and `Polygon` geometries. + /// "line-center" + /// The label is placed at the center of the line of the geometry. + /// Can only be used on `LineString` and `Polygon` geometries. Note + /// that a single feature in a vector tile may contain multiple line + /// geometries. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic symbolPlacement; + + /// Distance between two symbol anchors. + /// + /// Type: number + /// default: 250 + /// minimum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic symbolSpacing; + + /// If true, the symbols will not cross tile edges to avoid mutual + /// collisions. Recommended in layers that don't have enough padding in + /// the vector tile to prevent collisions, or if it is a point symbol + /// layer placed after a line symbol layer. When using a client that + /// supports global collision detection, like MapLibre GL JS, enabling + /// this property is not needed to prevent clipped labels at tile + /// boundaries. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic symbolAvoidEdges; + + /// Sorts features in ascending order based on this value. Features with + /// lower sort keys are drawn and placed first. When `icon-allow-overlap` + /// or `text-allow-overlap` is `false`, features with a lower sort key + /// will have priority during placement. When `icon-allow-overlap` or + /// `text-allow-overlap` is set to `true`, features with a higher sort key + /// will overlap over features with a lower sort key. + /// + /// Type: number + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic symbolSortKey; + + /// Controls the order in which overlapping symbols in the same layer are + /// rendered + /// + /// Type: enum + /// default: auto + /// Options: + /// "auto" + /// If `symbol-sort-key` is set, sort based on that. Otherwise sort + /// symbols by their y-position relative to the viewport. + /// "viewport-y" + /// Symbols will be sorted by their y-position relative to the + /// viewport. + /// "source" + /// Symbols will be rendered in the same order as the source data + /// with no sorting applied. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic symbolZOrder; + + /// If true, the icon will be visible even if it collides with other + /// previously drawn symbols. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconAllowOverlap; + + /// If true, other symbols can be visible even if they collide with the + /// icon. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconIgnorePlacement; + + /// If true, text will display without their corresponding icons when the + /// icon collides with other symbols and the text does not. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconOptional; + + /// In combination with `symbol-placement`, determines the rotation + /// behavior of icons. + /// + /// Type: enum + /// default: auto + /// Options: + /// "map" + /// When `symbol-placement` is set to `point`, aligns icons + /// east-west. When `symbol-placement` is set to `line` or + /// `line-center`, aligns icon x-axes with the line. + /// "viewport" + /// Produces icons whose x-axes are aligned with the x-axis of the + /// viewport, regardless of the value of `symbol-placement`. + /// "auto" + /// When `symbol-placement` is set to `point`, this is equivalent to + /// `viewport`. When `symbol-placement` is set to `line` or + /// `line-center`, this is equivalent to `map`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconRotationAlignment; + + /// Scales the original size of the icon by the provided factor. The new + /// pixel size of the image will be the original pixel size multiplied by + /// `icon-size`. 1 is the original size; 3 triples the size of the image. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconSize; + + /// Scales the icon to fit around the associated text. + /// + /// Type: enum + /// default: none + /// Options: + /// "none" + /// The icon is displayed at its intrinsic aspect ratio. + /// "width" + /// The icon is scaled in the x-dimension to fit the width of the + /// text. + /// "height" + /// The icon is scaled in the y-dimension to fit the height of the + /// text. + /// "both" + /// The icon is scaled in both x- and y-dimensions. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconTextFit; + + /// Size of the additional area added to dimensions determined by + /// `icon-text-fit`, in clockwise order: top, right, bottom, left. + /// + /// Type: array + /// default: [0, 0, 0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconTextFitPadding; + + /// Name of image in sprite to use for drawing an image background. + /// + /// Type: resolvedImage + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconImage; + + /// Rotates the icon clockwise. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconRotate; + + /// Size of the additional area around the icon bounding box used for + /// detecting symbol collisions. + /// + /// Type: number + /// default: 2 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconPadding; + + /// If true, the icon may be flipped to prevent it from being rendered + /// upside-down. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconKeepUpright; + + /// Offset distance of icon from its anchor. Positive values indicate + /// right and down, while negative values indicate left and up. Each + /// component is multiplied by the value of `icon-size` to obtain the + /// final offset in pixels. When combined with `icon-rotate` the offset + /// will be as if the rotated direction was up. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconOffset; + + /// Part of the icon placed closest to the anchor. + /// + /// Type: enum + /// default: center + /// Options: + /// "center" + /// The center of the icon is placed closest to the anchor. + /// "left" + /// The left side of the icon is placed closest to the anchor. + /// "right" + /// The right side of the icon is placed closest to the anchor. + /// "top" + /// The top of the icon is placed closest to the anchor. + /// "bottom" + /// The bottom of the icon is placed closest to the anchor. + /// "top-left" + /// The top left corner of the icon is placed closest to the anchor. + /// "top-right" + /// The top right corner of the icon is placed closest to the anchor. + /// "bottom-left" + /// The bottom left corner of the icon is placed closest to the + /// anchor. + /// "bottom-right" + /// The bottom right corner of the icon is placed closest to the + /// anchor. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic iconAnchor; + + /// Orientation of icon when map is pitched. + /// + /// Type: enum + /// default: auto + /// Options: + /// "map" + /// The icon is aligned to the plane of the map. + /// "viewport" + /// The icon is aligned to the plane of the viewport. + /// "auto" + /// Automatically matches the value of `icon-rotation-alignment`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic iconPitchAlignment; + + /// Orientation of text when map is pitched. + /// + /// Type: enum + /// default: auto + /// Options: + /// "map" + /// The text is aligned to the plane of the map. + /// "viewport" + /// The text is aligned to the plane of the viewport. + /// "auto" + /// Automatically matches the value of `text-rotation-alignment`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textPitchAlignment; + + /// In combination with `symbol-placement`, determines the rotation + /// behavior of the individual glyphs forming the text. + /// + /// Type: enum + /// default: auto + /// Options: + /// "map" + /// When `symbol-placement` is set to `point`, aligns text east-west. + /// When `symbol-placement` is set to `line` or `line-center`, aligns + /// text x-axes with the line. + /// "viewport" + /// Produces glyphs whose x-axes are aligned with the x-axis of the + /// viewport, regardless of the value of `symbol-placement`. + /// "auto" + /// When `symbol-placement` is set to `point`, this is equivalent to + /// `viewport`. When `symbol-placement` is set to `line` or + /// `line-center`, this is equivalent to `map`. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textRotationAlignment; + + /// Value to use for a text label. If a plain `string` is provided, it + /// will be treated as a `formatted` with default/inherited formatting + /// options. + /// + /// Type: formatted + /// default: + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textField; + + /// Font stack to use for displaying text. + /// + /// Type: array + /// default: [Open Sans Regular, Arial Unicode MS Regular] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textFont; + + /// Font size. + /// + /// Type: number + /// default: 16 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textSize; + + /// The maximum line width for text wrapping. + /// + /// Type: number + /// default: 10 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textMaxWidth; + + /// Text leading value for multi-line text. + /// + /// Type: number + /// default: 1.2 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textLineHeight; + + /// Text tracking amount. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textLetterSpacing; + + /// Text justification options. + /// + /// Type: enum + /// default: center + /// Options: + /// "auto" + /// The text is aligned towards the anchor position. + /// "left" + /// The text is aligned to the left. + /// "center" + /// The text is centered. + /// "right" + /// The text is aligned to the right. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textJustify; + + /// Radial offset of text, in the direction of the symbol's anchor. Useful + /// in combination with `text-variable-anchor`, which defaults to using + /// the two-dimensional `text-offset` if present. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textRadialOffset; + + /// To increase the chance of placing high-priority labels on the map, you + /// can provide an array of `text-anchor` locations: the renderer will + /// attempt to place the label at each location, in order, before moving + /// onto the next label. Use `text-justify: auto` to choose justification + /// based on anchor position. To apply an offset, use the + /// `text-radial-offset` or the two-dimensional `text-offset`. + /// + /// Type: array + /// Options: + /// "center" + /// The center of the text is placed closest to the anchor. + /// "left" + /// The left side of the text is placed closest to the anchor. + /// "right" + /// The right side of the text is placed closest to the anchor. + /// "top" + /// The top of the text is placed closest to the anchor. + /// "bottom" + /// The bottom of the text is placed closest to the anchor. + /// "top-left" + /// The top left corner of the text is placed closest to the anchor. + /// "top-right" + /// The top right corner of the text is placed closest to the anchor. + /// "bottom-left" + /// The bottom left corner of the text is placed closest to the + /// anchor. + /// "bottom-right" + /// The bottom right corner of the text is placed closest to the + /// anchor. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textVariableAnchor; + + /// Part of the text placed closest to the anchor. + /// + /// Type: enum + /// default: center + /// Options: + /// "center" + /// The center of the text is placed closest to the anchor. + /// "left" + /// The left side of the text is placed closest to the anchor. + /// "right" + /// The right side of the text is placed closest to the anchor. + /// "top" + /// The top of the text is placed closest to the anchor. + /// "bottom" + /// The bottom of the text is placed closest to the anchor. + /// "top-left" + /// The top left corner of the text is placed closest to the anchor. + /// "top-right" + /// The top right corner of the text is placed closest to the anchor. + /// "bottom-left" + /// The bottom left corner of the text is placed closest to the + /// anchor. + /// "bottom-right" + /// The bottom right corner of the text is placed closest to the + /// anchor. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textAnchor; + + /// Maximum angle change between adjacent characters. + /// + /// Type: number + /// default: 45 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textMaxAngle; + + /// The property allows control over a symbol's orientation. Note that the + /// property values act as a hint, so that a symbol whose language doesn’t + /// support the provided orientation will be laid out in its natural + /// orientation. Example: English point symbol will be rendered + /// horizontally even if array value contains single 'vertical' enum + /// value. The order of elements in an array define priority order for the + /// placement of an orientation variant. + /// + /// Type: array + /// Options: + /// "horizontal" + /// If a text's language supports horizontal writing mode, symbols + /// with point placement would be laid out horizontally. + /// "vertical" + /// If a text's language supports vertical writing mode, symbols with + /// point placement would be laid out vertically. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textWritingMode; + + /// Rotates the text clockwise. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textRotate; + + /// Size of the additional area around the text bounding box used for + /// detecting symbol collisions. + /// + /// Type: number + /// default: 2 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textPadding; + + /// If true, the text may be flipped vertically to prevent it from being + /// rendered upside-down. + /// + /// Type: boolean + /// default: true + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textKeepUpright; + + /// Specifies how to capitalize text, similar to the CSS `text-transform` + /// property. + /// + /// Type: enum + /// default: none + /// Options: + /// "none" + /// The text is not altered. + /// "uppercase" + /// Forces all letters to be displayed in uppercase. + /// "lowercase" + /// Forces all letters to be displayed in lowercase. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textTransform; + + /// Offset distance of text from its anchor. Positive values indicate + /// right and down, while negative values indicate left and up. If used + /// with text-variable-anchor, input values will be taken as absolute + /// values. Offsets along the x- and y-axis will be applied automatically + /// based on the anchor position. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic textOffset; + + /// If true, the text will be visible even if it collides with other + /// previously drawn symbols. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textAllowOverlap; + + /// If true, other symbols can be visible even if they collide with the + /// text. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textIgnorePlacement; + + /// If true, icons will display without their corresponding text when the + /// text collides with other symbols and the icon does not. + /// + /// Type: boolean + /// default: false + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic textOptional; + + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const SymbolLayerProperties({ + this.iconOpacity, + this.iconColor, + this.iconHaloColor, + this.iconHaloWidth, + this.iconHaloBlur, + this.iconTranslate, + this.iconTranslateAnchor, + this.textOpacity, + this.textColor, + this.textHaloColor, + this.textHaloWidth, + this.textHaloBlur, + this.textTranslate, + this.textTranslateAnchor, + this.symbolPlacement, + this.symbolSpacing, + this.symbolAvoidEdges, + this.symbolSortKey, + this.symbolZOrder, + this.iconAllowOverlap, + this.iconIgnorePlacement, + this.iconOptional, + this.iconRotationAlignment, + this.iconSize, + this.iconTextFit, + this.iconTextFitPadding, + this.iconImage, + this.iconRotate, + this.iconPadding, + this.iconKeepUpright, + this.iconOffset, + this.iconAnchor, + this.iconPitchAlignment, + this.textPitchAlignment, + this.textRotationAlignment, + this.textField, + this.textFont, + this.textSize, + this.textMaxWidth, + this.textLineHeight, + this.textLetterSpacing, + this.textJustify, + this.textRadialOffset, + this.textVariableAnchor, + this.textAnchor, + this.textMaxAngle, + this.textWritingMode, + this.textRotate, + this.textPadding, + this.textKeepUpright, + this.textTransform, + this.textOffset, + this.textAllowOverlap, + this.textIgnorePlacement, + this.textOptional, + this.visibility, + }); + + SymbolLayerProperties copyWith(SymbolLayerProperties changes) { + return SymbolLayerProperties( + iconOpacity: changes.iconOpacity ?? iconOpacity, + iconColor: changes.iconColor ?? iconColor, + iconHaloColor: changes.iconHaloColor ?? iconHaloColor, + iconHaloWidth: changes.iconHaloWidth ?? iconHaloWidth, + iconHaloBlur: changes.iconHaloBlur ?? iconHaloBlur, + iconTranslate: changes.iconTranslate ?? iconTranslate, + iconTranslateAnchor: changes.iconTranslateAnchor ?? iconTranslateAnchor, + textOpacity: changes.textOpacity ?? textOpacity, + textColor: changes.textColor ?? textColor, + textHaloColor: changes.textHaloColor ?? textHaloColor, + textHaloWidth: changes.textHaloWidth ?? textHaloWidth, + textHaloBlur: changes.textHaloBlur ?? textHaloBlur, + textTranslate: changes.textTranslate ?? textTranslate, + textTranslateAnchor: changes.textTranslateAnchor ?? textTranslateAnchor, + symbolPlacement: changes.symbolPlacement ?? symbolPlacement, + symbolSpacing: changes.symbolSpacing ?? symbolSpacing, + symbolAvoidEdges: changes.symbolAvoidEdges ?? symbolAvoidEdges, + symbolSortKey: changes.symbolSortKey ?? symbolSortKey, + symbolZOrder: changes.symbolZOrder ?? symbolZOrder, + iconAllowOverlap: changes.iconAllowOverlap ?? iconAllowOverlap, + iconIgnorePlacement: changes.iconIgnorePlacement ?? iconIgnorePlacement, + iconOptional: changes.iconOptional ?? iconOptional, + iconRotationAlignment: + changes.iconRotationAlignment ?? iconRotationAlignment, + iconSize: changes.iconSize ?? iconSize, + iconTextFit: changes.iconTextFit ?? iconTextFit, + iconTextFitPadding: changes.iconTextFitPadding ?? iconTextFitPadding, + iconImage: changes.iconImage ?? iconImage, + iconRotate: changes.iconRotate ?? iconRotate, + iconPadding: changes.iconPadding ?? iconPadding, + iconKeepUpright: changes.iconKeepUpright ?? iconKeepUpright, + iconOffset: changes.iconOffset ?? iconOffset, + iconAnchor: changes.iconAnchor ?? iconAnchor, + iconPitchAlignment: changes.iconPitchAlignment ?? iconPitchAlignment, + textPitchAlignment: changes.textPitchAlignment ?? textPitchAlignment, + textRotationAlignment: + changes.textRotationAlignment ?? textRotationAlignment, + textField: changes.textField ?? textField, + textFont: changes.textFont ?? textFont, + textSize: changes.textSize ?? textSize, + textMaxWidth: changes.textMaxWidth ?? textMaxWidth, + textLineHeight: changes.textLineHeight ?? textLineHeight, + textLetterSpacing: changes.textLetterSpacing ?? textLetterSpacing, + textJustify: changes.textJustify ?? textJustify, + textRadialOffset: changes.textRadialOffset ?? textRadialOffset, + textVariableAnchor: changes.textVariableAnchor ?? textVariableAnchor, + textAnchor: changes.textAnchor ?? textAnchor, + textMaxAngle: changes.textMaxAngle ?? textMaxAngle, + textWritingMode: changes.textWritingMode ?? textWritingMode, + textRotate: changes.textRotate ?? textRotate, + textPadding: changes.textPadding ?? textPadding, + textKeepUpright: changes.textKeepUpright ?? textKeepUpright, + textTransform: changes.textTransform ?? textTransform, + textOffset: changes.textOffset ?? textOffset, + textAllowOverlap: changes.textAllowOverlap ?? textAllowOverlap, + textIgnorePlacement: changes.textIgnorePlacement ?? textIgnorePlacement, + textOptional: changes.textOptional ?? textOptional, + visibility: changes.visibility ?? visibility, + ); + } + + @override + Map toJson({bool skipNulls = true}) { + final json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value == null && skipNulls) return; + json[fieldName] = value; + } + + addIfPresent('icon-opacity', iconOpacity); + addIfPresent('icon-color', iconColor); + addIfPresent('icon-halo-color', iconHaloColor); + addIfPresent('icon-halo-width', iconHaloWidth); + addIfPresent('icon-halo-blur', iconHaloBlur); + addIfPresent('icon-translate', iconTranslate); + addIfPresent('icon-translate-anchor', iconTranslateAnchor); + addIfPresent('text-opacity', textOpacity); + addIfPresent('text-color', textColor); + addIfPresent('text-halo-color', textHaloColor); + addIfPresent('text-halo-width', textHaloWidth); + addIfPresent('text-halo-blur', textHaloBlur); + addIfPresent('text-translate', textTranslate); + addIfPresent('text-translate-anchor', textTranslateAnchor); + addIfPresent('symbol-placement', symbolPlacement); + addIfPresent('symbol-spacing', symbolSpacing); + addIfPresent('symbol-avoid-edges', symbolAvoidEdges); + addIfPresent('symbol-sort-key', symbolSortKey); + addIfPresent('symbol-z-order', symbolZOrder); + addIfPresent('icon-allow-overlap', iconAllowOverlap); + addIfPresent('icon-ignore-placement', iconIgnorePlacement); + addIfPresent('icon-optional', iconOptional); + addIfPresent('icon-rotation-alignment', iconRotationAlignment); + addIfPresent('icon-size', iconSize); + addIfPresent('icon-text-fit', iconTextFit); + addIfPresent('icon-text-fit-padding', iconTextFitPadding); + addIfPresent('icon-image', iconImage); + addIfPresent('icon-rotate', iconRotate); + addIfPresent('icon-padding', iconPadding); + addIfPresent('icon-keep-upright', iconKeepUpright); + addIfPresent('icon-offset', iconOffset); + addIfPresent('icon-anchor', iconAnchor); + addIfPresent('icon-pitch-alignment', iconPitchAlignment); + addIfPresent('text-pitch-alignment', textPitchAlignment); + addIfPresent('text-rotation-alignment', textRotationAlignment); + addIfPresent('text-field', textField); + addIfPresent('text-font', textFont); + addIfPresent('text-size', textSize); + addIfPresent('text-max-width', textMaxWidth); + addIfPresent('text-line-height', textLineHeight); + addIfPresent('text-letter-spacing', textLetterSpacing); + addIfPresent('text-justify', textJustify); + addIfPresent('text-radial-offset', textRadialOffset); + addIfPresent('text-variable-anchor', textVariableAnchor); + addIfPresent('text-anchor', textAnchor); + addIfPresent('text-max-angle', textMaxAngle); + addIfPresent('text-writing-mode', textWritingMode); + addIfPresent('text-rotate', textRotate); + addIfPresent('text-padding', textPadding); + addIfPresent('text-keep-upright', textKeepUpright); + addIfPresent('text-transform', textTransform); + addIfPresent('text-offset', textOffset); + addIfPresent('text-allow-overlap', textAllowOverlap); + addIfPresent('text-ignore-placement', textIgnorePlacement); + addIfPresent('text-optional', textOptional); + addIfPresent('visibility', visibility); + return json; + } + + factory SymbolLayerProperties.fromJson(Map json) { + return SymbolLayerProperties( + iconOpacity: json['icon-opacity'], + iconColor: json['icon-color'], + iconHaloColor: json['icon-halo-color'], + iconHaloWidth: json['icon-halo-width'], + iconHaloBlur: json['icon-halo-blur'], + iconTranslate: json['icon-translate'], + iconTranslateAnchor: json['icon-translate-anchor'], + textOpacity: json['text-opacity'], + textColor: json['text-color'], + textHaloColor: json['text-halo-color'], + textHaloWidth: json['text-halo-width'], + textHaloBlur: json['text-halo-blur'], + textTranslate: json['text-translate'], + textTranslateAnchor: json['text-translate-anchor'], + symbolPlacement: json['symbol-placement'], + symbolSpacing: json['symbol-spacing'], + symbolAvoidEdges: json['symbol-avoid-edges'], + symbolSortKey: json['symbol-sort-key'], + symbolZOrder: json['symbol-z-order'], + iconAllowOverlap: json['icon-allow-overlap'], + iconIgnorePlacement: json['icon-ignore-placement'], + iconOptional: json['icon-optional'], + iconRotationAlignment: json['icon-rotation-alignment'], + iconSize: json['icon-size'], + iconTextFit: json['icon-text-fit'], + iconTextFitPadding: json['icon-text-fit-padding'], + iconImage: json['icon-image'], + iconRotate: json['icon-rotate'], + iconPadding: json['icon-padding'], + iconKeepUpright: json['icon-keep-upright'], + iconOffset: json['icon-offset'], + iconAnchor: json['icon-anchor'], + iconPitchAlignment: json['icon-pitch-alignment'], + textPitchAlignment: json['text-pitch-alignment'], + textRotationAlignment: json['text-rotation-alignment'], + textField: json['text-field'], + textFont: json['text-font'], + textSize: json['text-size'], + textMaxWidth: json['text-max-width'], + textLineHeight: json['text-line-height'], + textLetterSpacing: json['text-letter-spacing'], + textJustify: json['text-justify'], + textRadialOffset: json['text-radial-offset'], + textVariableAnchor: json['text-variable-anchor'], + textAnchor: json['text-anchor'], + textMaxAngle: json['text-max-angle'], + textWritingMode: json['text-writing-mode'], + textRotate: json['text-rotate'], + textPadding: json['text-padding'], + textKeepUpright: json['text-keep-upright'], + textTransform: json['text-transform'], + textOffset: json['text-offset'], + textAllowOverlap: json['text-allow-overlap'], + textIgnorePlacement: json['text-ignore-placement'], + textOptional: json['text-optional'], + visibility: json['visibility'], + ); + } +} + +class CircleLayerProperties implements LayerProperties { + // Paint Properties + /// Circle radius. + /// + /// Type: number + /// default: 5 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleRadius; + + /// The fill color of the circle. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleColor; + + /// Amount to blur the circle. 1 blurs the circle such that only the + /// centerpoint is full opacity. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleBlur; + + /// The opacity at which the circle will be drawn. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleOpacity; + + /// The geometry's offset. Values are [x, y] where negatives indicate left + /// and up, respectively. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic circleTranslate; + + /// Controls the frame of reference for `circle-translate`. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// The circle is translated relative to the map. + /// "viewport" + /// The circle is translated relative to the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic circleTranslateAnchor; + + /// Controls the scaling behavior of the circle when the map is pitched. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// Circles are scaled according to their apparent distance to the + /// camera. + /// "viewport" + /// Circles are not scaled. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic circlePitchScale; + + /// Orientation of circle when map is pitched. + /// + /// Type: enum + /// default: viewport + /// Options: + /// "map" + /// The circle is aligned to the plane of the map. + /// "viewport" + /// The circle is aligned to the plane of the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic circlePitchAlignment; + + /// The width of the circle's stroke. Strokes are placed outside of the + /// `circle-radius`. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleStrokeWidth; + + /// The stroke color of the circle. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleStrokeColor; + + /// The opacity of the circle's stroke. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic circleStrokeOpacity; + + // Layout Properties + /// Sorts features in ascending order based on this value. Features with a + /// higher sort key will appear above features with a lower sort key. + /// + /// Type: number + /// + /// Sdk Support: + /// basic functionality with js + /// data-driven styling with js + final dynamic circleSortKey; + + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const CircleLayerProperties({ + this.circleRadius, + this.circleColor, + this.circleBlur, + this.circleOpacity, + this.circleTranslate, + this.circleTranslateAnchor, + this.circlePitchScale, + this.circlePitchAlignment, + this.circleStrokeWidth, + this.circleStrokeColor, + this.circleStrokeOpacity, + this.circleSortKey, + this.visibility, + }); + + CircleLayerProperties copyWith(CircleLayerProperties changes) { + return CircleLayerProperties( + circleRadius: changes.circleRadius ?? circleRadius, + circleColor: changes.circleColor ?? circleColor, + circleBlur: changes.circleBlur ?? circleBlur, + circleOpacity: changes.circleOpacity ?? circleOpacity, + circleTranslate: changes.circleTranslate ?? circleTranslate, + circleTranslateAnchor: + changes.circleTranslateAnchor ?? circleTranslateAnchor, + circlePitchScale: changes.circlePitchScale ?? circlePitchScale, + circlePitchAlignment: + changes.circlePitchAlignment ?? circlePitchAlignment, + circleStrokeWidth: changes.circleStrokeWidth ?? circleStrokeWidth, + circleStrokeColor: changes.circleStrokeColor ?? circleStrokeColor, + circleStrokeOpacity: changes.circleStrokeOpacity ?? circleStrokeOpacity, + circleSortKey: changes.circleSortKey ?? circleSortKey, + visibility: changes.visibility ?? visibility, + ); + } + + @override + Map toJson({bool skipNulls = true}) { + final json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value == null && skipNulls) return; + json[fieldName] = value; + } + + addIfPresent('circle-radius', circleRadius); + addIfPresent('circle-color', circleColor); + addIfPresent('circle-blur', circleBlur); + addIfPresent('circle-opacity', circleOpacity); + addIfPresent('circle-translate', circleTranslate); + addIfPresent('circle-translate-anchor', circleTranslateAnchor); + addIfPresent('circle-pitch-scale', circlePitchScale); + addIfPresent('circle-pitch-alignment', circlePitchAlignment); + addIfPresent('circle-stroke-width', circleStrokeWidth); + addIfPresent('circle-stroke-color', circleStrokeColor); + addIfPresent('circle-stroke-opacity', circleStrokeOpacity); + addIfPresent('circle-sort-key', circleSortKey); + addIfPresent('visibility', visibility); + return json; + } + + factory CircleLayerProperties.fromJson(Map json) { + return CircleLayerProperties( + circleRadius: json['circle-radius'], + circleColor: json['circle-color'], + circleBlur: json['circle-blur'], + circleOpacity: json['circle-opacity'], + circleTranslate: json['circle-translate'], + circleTranslateAnchor: json['circle-translate-anchor'], + circlePitchScale: json['circle-pitch-scale'], + circlePitchAlignment: json['circle-pitch-alignment'], + circleStrokeWidth: json['circle-stroke-width'], + circleStrokeColor: json['circle-stroke-color'], + circleStrokeOpacity: json['circle-stroke-opacity'], + circleSortKey: json['circle-sort-key'], + visibility: json['visibility'], + ); + } +} + +class LineLayerProperties implements LayerProperties { + // Paint Properties + /// The opacity at which the line will be drawn. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineOpacity; + + /// The color with which the line will be drawn. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineColor; + + /// The geometry's offset. Values are [x, y] where negatives indicate left + /// and up, respectively. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineTranslate; + + /// Controls the frame of reference for `line-translate`. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// The line is translated relative to the map. + /// "viewport" + /// The line is translated relative to the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineTranslateAnchor; + + /// Stroke thickness. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineWidth; + + /// Draws a line casing outside of a line's actual path. Value indicates + /// the width of the inner gap. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineGapWidth; + + /// The line's offset. For linear features, a positive value offsets the + /// line to the right, relative to the direction of the line, and a + /// negative value to the left. For polygon features, a positive value + /// results in an inset, and a negative value results in an outset. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineOffset; + + /// Blur applied to the line, in pixels. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineBlur; + + /// Specifies the lengths of the alternating dashes and gaps that form the + /// dash pattern. The lengths are later scaled by the line width. To + /// convert a dash length to pixels, multiply the length by the current + /// line width. Note that GeoJSON sources with `lineMetrics: true` + /// specified won't render dashed lines to the expected scale. Also note + /// that zoom-dependent expressions will be evaluated only at integer zoom + /// levels. + /// + /// Type: array + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineDasharray; + + /// Name of image in sprite to use for drawing image lines. For seamless + /// patterns, image width must be a factor of two (2, 4, 8, ..., 512). + /// Note that zoom-dependent expressions will be evaluated only at integer + /// zoom levels. + /// + /// Type: resolvedImage + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, macos, ios + final dynamic linePattern; + + /// Defines a gradient with which to color a line feature. Can only be + /// used with GeoJSON sources that specify `"lineMetrics": true`. + /// + /// Type: color + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineGradient; + + // Layout Properties + /// The display of line endings. + /// + /// Type: enum + /// default: butt + /// Options: + /// "butt" + /// A cap with a squared-off end which is drawn to the exact endpoint + /// of the line. + /// "round" + /// A cap with a rounded end which is drawn beyond the endpoint of + /// the line at a radius of one-half of the line's width and centered + /// on the endpoint of the line. + /// "square" + /// A cap with a squared-off end which is drawn beyond the endpoint + /// of the line at a distance of one-half of the line's width. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineCap; + + /// The display of lines when joining. + /// + /// Type: enum + /// default: miter + /// Options: + /// "bevel" + /// A join with a squared-off end which is drawn beyond the endpoint + /// of the line at a distance of one-half of the line's width. + /// "round" + /// A join with a rounded end which is drawn beyond the endpoint of + /// the line at a radius of one-half of the line's width and centered + /// on the endpoint of the line. + /// "miter" + /// A join with a sharp, angled corner which is drawn with the outer + /// sides beyond the endpoint of the path until they meet. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic lineJoin; + + /// Used to automatically convert miter joins to bevel joins for sharp + /// angles. + /// + /// Type: number + /// default: 2 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineMiterLimit; + + /// Used to automatically convert round joins to miter joins for shallow + /// angles. + /// + /// Type: number + /// default: 1.05 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic lineRoundLimit; + + /// Sorts features in ascending order based on this value. Features with a + /// higher sort key will appear above features with a lower sort key. + /// + /// Type: number + /// + /// Sdk Support: + /// basic functionality with js + /// data-driven styling with js + final dynamic lineSortKey; + + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const LineLayerProperties({ + this.lineOpacity, + this.lineColor, + this.lineTranslate, + this.lineTranslateAnchor, + this.lineWidth, + this.lineGapWidth, + this.lineOffset, + this.lineBlur, + this.lineDasharray, + this.linePattern, + this.lineGradient, + this.lineCap, + this.lineJoin, + this.lineMiterLimit, + this.lineRoundLimit, + this.lineSortKey, + this.visibility, + }); + + LineLayerProperties copyWith(LineLayerProperties changes) { + return LineLayerProperties( + lineOpacity: changes.lineOpacity ?? lineOpacity, + lineColor: changes.lineColor ?? lineColor, + lineTranslate: changes.lineTranslate ?? lineTranslate, + lineTranslateAnchor: changes.lineTranslateAnchor ?? lineTranslateAnchor, + lineWidth: changes.lineWidth ?? lineWidth, + lineGapWidth: changes.lineGapWidth ?? lineGapWidth, + lineOffset: changes.lineOffset ?? lineOffset, + lineBlur: changes.lineBlur ?? lineBlur, + lineDasharray: changes.lineDasharray ?? lineDasharray, + linePattern: changes.linePattern ?? linePattern, + lineGradient: changes.lineGradient ?? lineGradient, + lineCap: changes.lineCap ?? lineCap, + lineJoin: changes.lineJoin ?? lineJoin, + lineMiterLimit: changes.lineMiterLimit ?? lineMiterLimit, + lineRoundLimit: changes.lineRoundLimit ?? lineRoundLimit, + lineSortKey: changes.lineSortKey ?? lineSortKey, + visibility: changes.visibility ?? visibility, + ); + } + + @override + Map toJson({bool skipNulls = true}) { + final json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value == null && skipNulls) return; + json[fieldName] = value; + } + + addIfPresent('line-opacity', lineOpacity); + addIfPresent('line-color', lineColor); + addIfPresent('line-translate', lineTranslate); + addIfPresent('line-translate-anchor', lineTranslateAnchor); + addIfPresent('line-width', lineWidth); + addIfPresent('line-gap-width', lineGapWidth); + addIfPresent('line-offset', lineOffset); + addIfPresent('line-blur', lineBlur); + addIfPresent('line-dasharray', lineDasharray); + addIfPresent('line-pattern', linePattern); + addIfPresent('line-gradient', lineGradient); + addIfPresent('line-cap', lineCap); + addIfPresent('line-join', lineJoin); + addIfPresent('line-miter-limit', lineMiterLimit); + addIfPresent('line-round-limit', lineRoundLimit); + addIfPresent('line-sort-key', lineSortKey); + addIfPresent('visibility', visibility); + return json; + } + + factory LineLayerProperties.fromJson(Map json) { + return LineLayerProperties( + lineOpacity: json['line-opacity'], + lineColor: json['line-color'], + lineTranslate: json['line-translate'], + lineTranslateAnchor: json['line-translate-anchor'], + lineWidth: json['line-width'], + lineGapWidth: json['line-gap-width'], + lineOffset: json['line-offset'], + lineBlur: json['line-blur'], + lineDasharray: json['line-dasharray'], + linePattern: json['line-pattern'], + lineGradient: json['line-gradient'], + lineCap: json['line-cap'], + lineJoin: json['line-join'], + lineMiterLimit: json['line-miter-limit'], + lineRoundLimit: json['line-round-limit'], + lineSortKey: json['line-sort-key'], + visibility: json['visibility'], + ); + } +} + +class FillLayerProperties implements LayerProperties { + // Paint Properties + /// Whether or not the fill should be antialiased. + /// + /// Type: boolean + /// default: true + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic fillAntialias; + + /// The opacity of the entire fill layer. In contrast to the `fill-color`, + /// this value will also affect the 1px stroke around the fill, if the + /// stroke is used. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic fillOpacity; + + /// The color of the filled part of this layer. This color can be + /// specified as `rgba` with an alpha component and the color's opacity + /// will not affect the opacity of the 1px stroke, if it is used. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic fillColor; + + /// The outline color of the fill. Matches the value of `fill-color` if + /// unspecified. + /// + /// Type: color + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic fillOutlineColor; + + /// The geometry's offset. Values are [x, y] where negatives indicate left + /// and up, respectively. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic fillTranslate; + + /// Controls the frame of reference for `fill-translate`. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// The fill is translated relative to the map. + /// "viewport" + /// The fill is translated relative to the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic fillTranslateAnchor; + + /// Name of image in sprite to use for drawing image fills. For seamless + /// patterns, image width and height must be a factor of two (2, 4, 8, + /// ..., 512). Note that zoom-dependent expressions will be evaluated only + /// at integer zoom levels. + /// + /// Type: resolvedImage + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, macos, ios + final dynamic fillPattern; + + // Layout Properties + /// Sorts features in ascending order based on this value. Features with a + /// higher sort key will appear above features with a lower sort key. + /// + /// Type: number + /// + /// Sdk Support: + /// basic functionality with js + /// data-driven styling with js + final dynamic fillSortKey; + + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const FillLayerProperties({ + this.fillAntialias, + this.fillOpacity, + this.fillColor, + this.fillOutlineColor, + this.fillTranslate, + this.fillTranslateAnchor, + this.fillPattern, + this.fillSortKey, + this.visibility, + }); + + FillLayerProperties copyWith(FillLayerProperties changes) { + return FillLayerProperties( + fillAntialias: changes.fillAntialias ?? fillAntialias, + fillOpacity: changes.fillOpacity ?? fillOpacity, + fillColor: changes.fillColor ?? fillColor, + fillOutlineColor: changes.fillOutlineColor ?? fillOutlineColor, + fillTranslate: changes.fillTranslate ?? fillTranslate, + fillTranslateAnchor: changes.fillTranslateAnchor ?? fillTranslateAnchor, + fillPattern: changes.fillPattern ?? fillPattern, + fillSortKey: changes.fillSortKey ?? fillSortKey, + visibility: changes.visibility ?? visibility, + ); + } + + @override + Map toJson({bool skipNulls = true}) { + final json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value == null && skipNulls) return; + json[fieldName] = value; + } + + addIfPresent('fill-antialias', fillAntialias); + addIfPresent('fill-opacity', fillOpacity); + addIfPresent('fill-color', fillColor); + addIfPresent('fill-outline-color', fillOutlineColor); + addIfPresent('fill-translate', fillTranslate); + addIfPresent('fill-translate-anchor', fillTranslateAnchor); + addIfPresent('fill-pattern', fillPattern); + addIfPresent('fill-sort-key', fillSortKey); + addIfPresent('visibility', visibility); + return json; + } + + factory FillLayerProperties.fromJson(Map json) { + return FillLayerProperties( + fillAntialias: json['fill-antialias'], + fillOpacity: json['fill-opacity'], + fillColor: json['fill-color'], + fillOutlineColor: json['fill-outline-color'], + fillTranslate: json['fill-translate'], + fillTranslateAnchor: json['fill-translate-anchor'], + fillPattern: json['fill-pattern'], + fillSortKey: json['fill-sort-key'], + visibility: json['visibility'], + ); + } +} + +class FillExtrusionLayerProperties implements LayerProperties { + // Paint Properties + /// The opacity of the entire fill extrusion layer. This is rendered on a + /// per-layer, not per-feature, basis, and data-driven styling is not + /// available. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic fillExtrusionOpacity; + + /// The base color of the extruded fill. The extrusion's surfaces will be + /// shaded differently based on this color in combination with the root + /// `light` settings. If this color is specified as `rgba` with an alpha + /// component, the alpha component will be ignored; use + /// `fill-extrusion-opacity` to set layer opacity. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic fillExtrusionColor; + + /// The geometry's offset. Values are [x, y] where negatives indicate left + /// and up (on the flat plane), respectively. + /// + /// Type: array + /// default: [0, 0] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic fillExtrusionTranslate; + + /// Controls the frame of reference for `fill-extrusion-translate`. + /// + /// Type: enum + /// default: map + /// Options: + /// "map" + /// The fill extrusion is translated relative to the map. + /// "viewport" + /// The fill extrusion is translated relative to the viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic fillExtrusionTranslateAnchor; + + /// Name of image in sprite to use for drawing images on extruded fills. + /// For seamless patterns, image width and height must be a factor of two + /// (2, 4, 8, ..., 512). Note that zoom-dependent expressions will be + /// evaluated only at integer zoom levels. + /// + /// Type: resolvedImage + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, macos, ios + final dynamic fillExtrusionPattern; + + /// The height with which to extrude this layer. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic fillExtrusionHeight; + + /// The height with which to extrude the base of this layer. Must be less + /// than or equal to `fill-extrusion-height`. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic fillExtrusionBase; + + /// Whether to apply a vertical gradient to the sides of a fill-extrusion + /// layer. If true, sides will be shaded slightly darker farther down. + /// + /// Type: boolean + /// default: true + /// + /// Sdk Support: + /// basic functionality with js, ios, macos + final dynamic fillExtrusionVerticalGradient; + + // Layout Properties + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const FillExtrusionLayerProperties({ + this.fillExtrusionOpacity, + this.fillExtrusionColor, + this.fillExtrusionTranslate, + this.fillExtrusionTranslateAnchor, + this.fillExtrusionPattern, + this.fillExtrusionHeight, + this.fillExtrusionBase, + this.fillExtrusionVerticalGradient, + this.visibility, + }); + + FillExtrusionLayerProperties copyWith(FillExtrusionLayerProperties changes) { + return FillExtrusionLayerProperties( + fillExtrusionOpacity: + changes.fillExtrusionOpacity ?? fillExtrusionOpacity, + fillExtrusionColor: changes.fillExtrusionColor ?? fillExtrusionColor, + fillExtrusionTranslate: + changes.fillExtrusionTranslate ?? fillExtrusionTranslate, + fillExtrusionTranslateAnchor: + changes.fillExtrusionTranslateAnchor ?? fillExtrusionTranslateAnchor, + fillExtrusionPattern: + changes.fillExtrusionPattern ?? fillExtrusionPattern, + fillExtrusionHeight: changes.fillExtrusionHeight ?? fillExtrusionHeight, + fillExtrusionBase: changes.fillExtrusionBase ?? fillExtrusionBase, + fillExtrusionVerticalGradient: changes.fillExtrusionVerticalGradient ?? + fillExtrusionVerticalGradient, + visibility: changes.visibility ?? visibility, + ); + } + + @override + Map toJson({bool skipNulls = true}) { + final json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value == null && skipNulls) return; + json[fieldName] = value; + } + + addIfPresent('fill-extrusion-opacity', fillExtrusionOpacity); + addIfPresent('fill-extrusion-color', fillExtrusionColor); + addIfPresent('fill-extrusion-translate', fillExtrusionTranslate); + addIfPresent( + 'fill-extrusion-translate-anchor', fillExtrusionTranslateAnchor); + addIfPresent('fill-extrusion-pattern', fillExtrusionPattern); + addIfPresent('fill-extrusion-height', fillExtrusionHeight); + addIfPresent('fill-extrusion-base', fillExtrusionBase); + addIfPresent( + 'fill-extrusion-vertical-gradient', fillExtrusionVerticalGradient); + addIfPresent('visibility', visibility); + return json; + } + + factory FillExtrusionLayerProperties.fromJson(Map json) { + return FillExtrusionLayerProperties( + fillExtrusionOpacity: json['fill-extrusion-opacity'], + fillExtrusionColor: json['fill-extrusion-color'], + fillExtrusionTranslate: json['fill-extrusion-translate'], + fillExtrusionTranslateAnchor: json['fill-extrusion-translate-anchor'], + fillExtrusionPattern: json['fill-extrusion-pattern'], + fillExtrusionHeight: json['fill-extrusion-height'], + fillExtrusionBase: json['fill-extrusion-base'], + fillExtrusionVerticalGradient: json['fill-extrusion-vertical-gradient'], + visibility: json['visibility'], + ); + } +} + +class RasterLayerProperties implements LayerProperties { + // Paint Properties + /// The opacity at which the image will be drawn. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterOpacity; + + /// Rotates hues around the color wheel. + /// + /// Type: number + /// default: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterHueRotate; + + /// Increase or reduce the brightness of the image. The value is the + /// minimum brightness. + /// + /// Type: number + /// default: 0 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterBrightnessMin; + + /// Increase or reduce the brightness of the image. The value is the + /// maximum brightness. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterBrightnessMax; + + /// Increase or reduce the saturation of the image. + /// + /// Type: number + /// default: 0 + /// minimum: -1 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterSaturation; + + /// Increase or reduce the contrast of the image. + /// + /// Type: number + /// default: 0 + /// minimum: -1 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterContrast; + + /// The resampling/interpolation method to use for overscaling, also known + /// as texture magnification filter + /// + /// Type: enum + /// default: linear + /// Options: + /// "linear" + /// (Bi)linear filtering interpolates pixel values using the weighted + /// average of the four closest original source pixels creating a + /// smooth but blurry look when overscaled + /// "nearest" + /// Nearest neighbor filtering interpolates pixel values using the + /// nearest original source pixel creating a sharp but pixelated look + /// when overscaled + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterResampling; + + /// Fade duration when a new tile is added. + /// + /// Type: number + /// default: 300 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic rasterFadeDuration; + + // Layout Properties + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const RasterLayerProperties({ + this.rasterOpacity, + this.rasterHueRotate, + this.rasterBrightnessMin, + this.rasterBrightnessMax, + this.rasterSaturation, + this.rasterContrast, + this.rasterResampling, + this.rasterFadeDuration, + this.visibility, + }); + + RasterLayerProperties copyWith(RasterLayerProperties changes) { + return RasterLayerProperties( + rasterOpacity: changes.rasterOpacity ?? rasterOpacity, + rasterHueRotate: changes.rasterHueRotate ?? rasterHueRotate, + rasterBrightnessMin: changes.rasterBrightnessMin ?? rasterBrightnessMin, + rasterBrightnessMax: changes.rasterBrightnessMax ?? rasterBrightnessMax, + rasterSaturation: changes.rasterSaturation ?? rasterSaturation, + rasterContrast: changes.rasterContrast ?? rasterContrast, + rasterResampling: changes.rasterResampling ?? rasterResampling, + rasterFadeDuration: changes.rasterFadeDuration ?? rasterFadeDuration, + visibility: changes.visibility ?? visibility, + ); + } + + @override + Map toJson({bool skipNulls = true}) { + final json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value == null && skipNulls) return; + json[fieldName] = value; + } + + addIfPresent('raster-opacity', rasterOpacity); + addIfPresent('raster-hue-rotate', rasterHueRotate); + addIfPresent('raster-brightness-min', rasterBrightnessMin); + addIfPresent('raster-brightness-max', rasterBrightnessMax); + addIfPresent('raster-saturation', rasterSaturation); + addIfPresent('raster-contrast', rasterContrast); + addIfPresent('raster-resampling', rasterResampling); + addIfPresent('raster-fade-duration', rasterFadeDuration); + addIfPresent('visibility', visibility); + return json; + } + + factory RasterLayerProperties.fromJson(Map json) { + return RasterLayerProperties( + rasterOpacity: json['raster-opacity'], + rasterHueRotate: json['raster-hue-rotate'], + rasterBrightnessMin: json['raster-brightness-min'], + rasterBrightnessMax: json['raster-brightness-max'], + rasterSaturation: json['raster-saturation'], + rasterContrast: json['raster-contrast'], + rasterResampling: json['raster-resampling'], + rasterFadeDuration: json['raster-fade-duration'], + visibility: json['visibility'], + ); + } +} + +class HillshadeLayerProperties implements LayerProperties { + // Paint Properties + /// The direction of the light source used to generate the hillshading + /// with 0 as the top of the viewport if `hillshade-illumination-anchor` + /// is set to `viewport` and due north if `hillshade-illumination-anchor` + /// is set to `map`. + /// + /// Type: number + /// default: 335 + /// minimum: 0 + /// maximum: 359 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic hillshadeIlluminationDirection; + + /// Direction of light source when map is rotated. + /// + /// Type: enum + /// default: viewport + /// Options: + /// "map" + /// The hillshade illumination is relative to the north direction. + /// "viewport" + /// The hillshade illumination is relative to the top of the + /// viewport. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic hillshadeIlluminationAnchor; + + /// Intensity of the hillshade + /// + /// Type: number + /// default: 0.5 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic hillshadeExaggeration; + + /// The shading color of areas that face away from the light source. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic hillshadeShadowColor; + + /// The shading color of areas that faces towards the light source. + /// + /// Type: color + /// default: #FFFFFF + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic hillshadeHighlightColor; + + /// The shading color used to accentuate rugged terrain like sharp cliffs + /// and gorges. + /// + /// Type: color + /// default: #000000 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic hillshadeAccentColor; + + // Layout Properties + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const HillshadeLayerProperties({ + this.hillshadeIlluminationDirection, + this.hillshadeIlluminationAnchor, + this.hillshadeExaggeration, + this.hillshadeShadowColor, + this.hillshadeHighlightColor, + this.hillshadeAccentColor, + this.visibility, + }); + + HillshadeLayerProperties copyWith(HillshadeLayerProperties changes) { + return HillshadeLayerProperties( + hillshadeIlluminationDirection: changes.hillshadeIlluminationDirection ?? + hillshadeIlluminationDirection, + hillshadeIlluminationAnchor: + changes.hillshadeIlluminationAnchor ?? hillshadeIlluminationAnchor, + hillshadeExaggeration: + changes.hillshadeExaggeration ?? hillshadeExaggeration, + hillshadeShadowColor: + changes.hillshadeShadowColor ?? hillshadeShadowColor, + hillshadeHighlightColor: + changes.hillshadeHighlightColor ?? hillshadeHighlightColor, + hillshadeAccentColor: + changes.hillshadeAccentColor ?? hillshadeAccentColor, + visibility: changes.visibility ?? visibility, + ); + } + + @override + Map toJson({bool skipNulls = true}) { + final json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value == null && skipNulls) return; + json[fieldName] = value; + } + + addIfPresent( + 'hillshade-illumination-direction', hillshadeIlluminationDirection); + addIfPresent('hillshade-illumination-anchor', hillshadeIlluminationAnchor); + addIfPresent('hillshade-exaggeration', hillshadeExaggeration); + addIfPresent('hillshade-shadow-color', hillshadeShadowColor); + addIfPresent('hillshade-highlight-color', hillshadeHighlightColor); + addIfPresent('hillshade-accent-color', hillshadeAccentColor); + addIfPresent('visibility', visibility); + return json; + } + + factory HillshadeLayerProperties.fromJson(Map json) { + return HillshadeLayerProperties( + hillshadeIlluminationDirection: json['hillshade-illumination-direction'], + hillshadeIlluminationAnchor: json['hillshade-illumination-anchor'], + hillshadeExaggeration: json['hillshade-exaggeration'], + hillshadeShadowColor: json['hillshade-shadow-color'], + hillshadeHighlightColor: json['hillshade-highlight-color'], + hillshadeAccentColor: json['hillshade-accent-color'], + visibility: json['visibility'], + ); + } +} + +class HeatmapLayerProperties implements LayerProperties { + // Paint Properties + /// Radius of influence of one heatmap point in pixels. Increasing the + /// value makes the heatmap smoother, but less detailed. + /// + /// Type: number + /// default: 30 + /// minimum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic heatmapRadius; + + /// A measure of how much an individual point contributes to the heatmap. + /// A value of 10 would be equivalent to having 10 points of weight 1 in + /// the same spot. Especially useful when combined with clustering. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + /// data-driven styling with js, android, ios, macos + final dynamic heatmapWeight; + + /// Similar to `heatmap-weight` but controls the intensity of the heatmap + /// globally. Primarily used for adjusting the heatmap based on zoom + /// level. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic heatmapIntensity; + + /// Defines the color of each pixel based on its density value in a + /// heatmap. Should be an expression that uses `["heatmap-density"]` as + /// input. + /// + /// Type: color + /// default: [interpolate, [linear], [heatmap-density], 0, rgba(0, 0, 255, 0), 0.1, royalblue, 0.3, cyan, 0.5, lime, 0.7, yellow, 1, red] + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic heatmapColor; + + /// The global opacity at which the heatmap layer will be drawn. + /// + /// Type: number + /// default: 1 + /// minimum: 0 + /// maximum: 1 + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic heatmapOpacity; + + // Layout Properties + /// Whether this layer is displayed. + /// + /// Type: enum + /// default: visible + /// Options: + /// "visible" + /// The layer is shown. + /// "none" + /// The layer is not shown. + /// + /// Sdk Support: + /// basic functionality with js, android, ios, macos + final dynamic visibility; + + const HeatmapLayerProperties({ + this.heatmapRadius, + this.heatmapWeight, + this.heatmapIntensity, + this.heatmapColor, + this.heatmapOpacity, + this.visibility, + }); + + HeatmapLayerProperties copyWith(HeatmapLayerProperties changes) { + return HeatmapLayerProperties( + heatmapRadius: changes.heatmapRadius ?? heatmapRadius, + heatmapWeight: changes.heatmapWeight ?? heatmapWeight, + heatmapIntensity: changes.heatmapIntensity ?? heatmapIntensity, + heatmapColor: changes.heatmapColor ?? heatmapColor, + heatmapOpacity: changes.heatmapOpacity ?? heatmapOpacity, + visibility: changes.visibility ?? visibility, + ); + } + + @override + Map toJson({bool skipNulls = true}) { + final json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value == null && skipNulls) return; + json[fieldName] = value; + } + + addIfPresent('heatmap-radius', heatmapRadius); + addIfPresent('heatmap-weight', heatmapWeight); + addIfPresent('heatmap-intensity', heatmapIntensity); + addIfPresent('heatmap-color', heatmapColor); + addIfPresent('heatmap-opacity', heatmapOpacity); + addIfPresent('visibility', visibility); + return json; + } + + factory HeatmapLayerProperties.fromJson(Map json) { + return HeatmapLayerProperties( + heatmapRadius: json['heatmap-radius'], + heatmapWeight: json['heatmap-weight'], + heatmapIntensity: json['heatmap-intensity'], + heatmapColor: json['heatmap-color'], + heatmapOpacity: json['heatmap-opacity'], + visibility: json['visibility'], + ); + } +} diff --git a/third_party/maplibre_gl/lib/src/maplibre_map.dart b/third_party/maplibre_gl/lib/src/maplibre_map.dart new file mode 100644 index 00000000..87f4ba50 --- /dev/null +++ b/third_party/maplibre_gl/lib/src/maplibre_map.dart @@ -0,0 +1,563 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +part of '../maplibre_gl.dart'; + +enum AnnotationType { fill, line, circle, symbol } + +typedef MapCreatedCallback = void Function(MapLibreMapController controller); + +@Deprecated('MaplibreMap was renamed to MapLibreMap. ') +typedef MaplibreMap = MapLibreMap; + +/// Shows a MapLibre map. +/// Also refer to the documentation of [maplibre_gl] and [MapLibreMapController]. +class MapLibreMap extends StatefulWidget { + const MapLibreMap({ + super.key, + required this.initialCameraPosition, + this.styleString = MapLibreStyles.demo, + this.onMapCreated, + this.onStyleLoadedCallback, + this.locationEnginePlatforms = LocationEnginePlatforms.defaultPlatform, + this.gestureRecognizers, + this.compassEnabled = true, + this.cameraTargetBounds = CameraTargetBounds.unbounded, + this.minMaxZoomPreference = MinMaxZoomPreference.unbounded, + this.rotateGesturesEnabled = true, + this.scrollGesturesEnabled = true, + this.zoomGesturesEnabled = true, + this.tiltGesturesEnabled = true, + this.doubleClickZoomEnabled, + this.dragEnabled = true, + this.trackCameraPosition = false, + this.myLocationEnabled = false, + this.myLocationTrackingMode = MyLocationTrackingMode.none, + this.myLocationRenderMode = MyLocationRenderMode.normal, + this.logoEnabled = false, + this.logoViewPosition, + this.logoViewMargins, + this.compassViewPosition, + this.compassViewMargins, + this.attributionButtonPosition = AttributionButtonPosition.bottomRight, + this.attributionButtonMargins, + this.iosLongClickDuration, + this.webPreserveDrawingBuffer = false, + this.onMapClick, + this.onUserLocationUpdated, + this.onMapLongClick, + this.onCameraTrackingDismissed, + this.onCameraTrackingChanged, + this.onCameraMove, + this.onCameraIdle, + this.onMapIdle, + this.annotationOrder = const [ + AnnotationType.line, + AnnotationType.symbol, + AnnotationType.circle, + AnnotationType.fill, + ], + this.annotationConsumeTapEvents = const [ + AnnotationType.symbol, + AnnotationType.fill, + AnnotationType.line, + AnnotationType.circle, + ], + this.foregroundLoadColor = Colors.transparent, + this.translucentTextureSurface = false, + }) : assert( + myLocationRenderMode == MyLocationRenderMode.normal || + myLocationEnabled, + "$myLocationRenderMode requires [myLocationEnabled] set to true.", + ), + assert(annotationOrder.length <= 4), + assert(annotationConsumeTapEvents.length > 0); + + /// The properties for the platform-specific location engine. + /// Only has an impact if [myLocationEnabled] is set to true. + final LocationEnginePlatforms locationEnginePlatforms; + + /// The color used for the map loading foreground. + /// Pass a [Color] and it will be converted to ARGB int for the platform. + /// + /// **Available only on Android. Has no effect on iOS or Web.** + final Color? foregroundLoadColor; + + /// Enable translucent texture surface for the map. + /// This allows the map to have a transparent background, useful for overlay scenarios. + /// + /// **Available only on Android. Has no effect on iOS or Web.** + final bool translucentTextureSurface; + + /// Defines the layer order of annotations displayed on map. + /// Order them from bottom to top. Bottom annotation will be rendered first. + /// + /// Any annotation type can only be contained once, so 0 to 4 types. + /// + /// Note that setting this to be empty gives a big perfomance boost for + /// android. However if you do so annotations will not work. + final List annotationOrder; + + /// Defines the layer order of click annotations + /// + /// (must contain at least 1 annotation type, 4 items max) + final List annotationConsumeTapEvents; + + /// Please note: you should only add annotations (e.g. symbols or circles) after `onStyleLoadedCallback` has been called. + final MapCreatedCallback? onMapCreated; + + /// Called when the map style has been successfully loaded and the annotation managers have been enabled. + /// Please note: you should only add annotations (e.g. symbols or circles) after this callback has been called. + final OnStyleLoadedCallback? onStyleLoadedCallback; + + /// The initial position of the map's camera. + final CameraPosition initialCameraPosition; + + /// How long a user has to click the map **on iOS** until a long click is registered. + /// Has no effect on web or Android. Can not be changed at runtime, only the initial value is used. + /// If null, the default value of the native MapLibre library / of the OS is used. + final Duration? iosLongClickDuration; + + /// If true, the map's canvas can be exported to a PNG using map.getCanvas().toDataURL(). + /// This is false by default as a performance optimization. + /// **Web only** - has no effect on other platforms. + final bool? webPreserveDrawingBuffer; + + /// True if the map should show a compass when rotated. + final bool compassEnabled; + + /// True if drag functionality should be enabled. + /// + /// Disable to avoid performance issues that from the drag event listeners. + /// Biggest impact in android + final bool dragEnabled; + + /// Geographical bounding box for the camera target. + final CameraTargetBounds cameraTargetBounds; + + /// A MapLibre GL style document defining the map's appearance. + /// The style document specification is at [https://maplibre.org/maplibre-style-spec]. + /// A short introduction can be found in the documentation of the [maplibre_gl] library. + /// The following formats are supported: + /// + /// 1. Passing the URL of the map style. This should be a custom map style served remotely using a URL that start with 'http(s)://' + /// 2. Passing the style as a local asset. Create a JSON file in the `assets` and add a reference in `pubspec.yml`. Set the style string to the relative path for this asset in order to load it into the map. + /// 3. Passing the style as a local file. create an JSON file in app directory (e.g. ApplicationDocumentsDirectory). Set the style string to the absolute path of this JSON file. + /// 4. Passing the raw JSON of the map style. + final String styleString; + + /// Preferred bounds for the camera zoom level. + /// + /// Actual bounds depend on map data and device. + final MinMaxZoomPreference minMaxZoomPreference; + + /// True if the map view should respond to rotate gestures. + final bool rotateGesturesEnabled; + + /// True if the map view should respond to scroll gestures. + final bool scrollGesturesEnabled; + + /// True if the map view should respond to zoom gestures. + final bool zoomGesturesEnabled; + + /// True if the map view should respond to tilt gestures. + final bool tiltGesturesEnabled; + + /// Set to true to forcefully disable/enable if map should respond to double + /// click to zoom. + /// + /// This takes presedence over zoomGesturesEnabled. Only supported for web. + final bool? doubleClickZoomEnabled; + + /// True if you want to be notified of map camera movements by the [MapLibreMapController]. Default is false. + /// + /// If this is set to true and the user pans/zooms/rotates the map, [MapLibreMapController] (which is a [ChangeNotifier]) + /// will notify it's listeners and you can then get the new [MapLibreMapController].cameraPosition. + final bool trackCameraPosition; + + /// True if a "My Location" layer should be shown on the map. + /// + /// This layer includes a location indicator at the current device location, + /// as well as a My Location button. + /// * The indicator is a small blue dot if the device is stationary, or a + /// chevron if the device is moving. + /// * The My Location button animates to focus on the user's current location + /// if the user's location is currently known. + /// + /// Enabling this feature requires adding location permissions to both native + /// platforms of your app. + /// * On Android add either + /// `` + /// or `` + /// to your `AndroidManifest.xml` file. `ACCESS_COARSE_LOCATION` returns a + /// location with an accuracy approximately equivalent to a city block, while + /// `ACCESS_FINE_LOCATION` returns as precise a location as possible, although + /// it consumes more battery power. You will also need to request these + /// permissions during run-time. If they are not granted, the My Location + /// feature will fail silently. + /// * On iOS add a `NSLocationWhenInUseUsageDescription` key to your + /// `Info.plist` file. This will automatically prompt the user for permissions + /// when the map tries to turn on the My Location layer. + final bool myLocationEnabled; + + /// The mode used to let the map's camera follow the device's physical location. + /// `myLocationEnabled` needs to be true for values other than `MyLocationTrackingMode.None` to work. + final MyLocationTrackingMode myLocationTrackingMode; + + /// Specifies if and how the user's heading/bearing is rendered in the user location indicator. + /// See the documentation of [MyLocationRenderMode] for details. + /// If this is set to a value other than [MyLocationRenderMode.normal], [myLocationEnabled] needs to be true. + final MyLocationRenderMode myLocationRenderMode; + + /// True if the MapLibre logo should be shown on the map. + /// Defaults to false. + final bool logoEnabled; + + /// Set the position for the Logo + final LogoViewPosition? logoViewPosition; + + /// Set the layout margins for the Logo + final Point? logoViewMargins; + + /// Set the position for the Compass + final CompassViewPosition? compassViewPosition; + + /// Set the layout margins for the Compass + final Point? compassViewMargins; + + /// Set the position for the MapLibre Attribution Button + /// When set to null, the default value of the underlying MapLibre libraries is used, + /// which differs depending on the operating system the app is being run on. + final AttributionButtonPosition? attributionButtonPosition; + + /// Set the layout margins for the MapLibre Attribution Buttons. If you set this + /// value, you may also want to set [attributionButtonPosition] to harmonize + /// the layout between iOS and Android, since the underlying frameworks have + /// different defaults. + final Point? attributionButtonMargins; + + /// Which gestures should be consumed by the map. + /// + /// It is possible for other gesture recognizers to be competing with the map on pointer + /// events, e.g if the map is inside a [ListView] the [ListView] will want to handle + /// vertical drags. The map will claim gestures that are recognized by any of the + /// recognizers on this list. + /// + /// When this set is empty or null, the map will only handle pointer events for gestures that + /// were not claimed by any other gesture recognizer. + final Set>? gestureRecognizers; + + final OnMapClickCallback? onMapClick; + final OnMapClickCallback? onMapLongClick; + + /// While the `myLocationEnabled` property is set to `true`, this method is + /// called whenever a new location update is received by the map view. + final OnUserLocationUpdated? onUserLocationUpdated; + + /// Called when the map's camera no longer follows the physical device location, e.g. because the user moved the map + final OnCameraTrackingDismissedCallback? onCameraTrackingDismissed; + + /// Called when the location tracking mode changes + final OnCameraTrackingChangedCallback? onCameraTrackingChanged; + + /// Called when camera is moving. + final OnCameraMoveCallback? onCameraMove; + + /// Called when camera movement has ended. + final OnCameraIdleCallback? onCameraIdle; + + /// Called when map view is entering an idle state, and no more drawing will + /// be necessary until new data is loaded or there is some interaction with + /// the map. + /// * No camera transitions are in progress + /// * All currently requested tiles have loaded + /// * All fade/transition animations have completed + final OnMapIdleCallback? onMapIdle; + + /// Set `MapLibreMap.useHybridComposition` to `false` in order use Virtual-Display + /// (better for Android 9 and below but may result in errors on Android 12) + /// or leave it `true` (default) to use Hybrid composition (Slower on Android 9 and below). + static bool get useHybridComposition => + MapLibreMethodChannel.useHybridComposition; + + static set useHybridComposition(bool useHybridComposition) => + MapLibreMethodChannel.useHybridComposition = useHybridComposition; + + @override + State createState() => _MapLibreMapState(); +} + +class _MapLibreMapState extends State { + final Completer _controller = + Completer(); + MapLibreMapController? _mapController; + + late _MapLibreMapOptions _maplibreMapOptions; + final MapLibrePlatform _maplibrePlatform = MapLibrePlatform.createInstance(); + + @override + Widget build(BuildContext context) { + assert( + widget.annotationOrder.toSet().length == widget.annotationOrder.length, + "annotationOrder must not have duplicate types"); + final creationParams = { + 'initialCameraPosition': widget.initialCameraPosition.toMap(), + 'styleString': widget.styleString, + 'options': _MapLibreMapOptions.fromWidget(widget).toMap(), + 'dragEnabled': widget.dragEnabled, + if (widget.iosLongClickDuration != null) + 'iosLongClickDurationMilliseconds': + widget.iosLongClickDuration!.inMilliseconds, + if (widget.webPreserveDrawingBuffer != null) + 'webPreserveDrawingBuffer': widget.webPreserveDrawingBuffer, + }; + return _maplibrePlatform.buildView( + creationParams, onPlatformViewCreated, widget.gestureRecognizers); + } + + @override + void initState() { + super.initState(); + _maplibreMapOptions = _MapLibreMapOptions.fromWidget(widget); + } + + @override + void dispose() { + if (_controller.isCompleted) { + _mapController?.dispose(); + } + + super.dispose(); + } + + @override + void didUpdateWidget(MapLibreMap oldWidget) { + super.didUpdateWidget(oldWidget); + final newOptions = _MapLibreMapOptions.fromWidget(widget); + final updates = _maplibreMapOptions.updatesMap(newOptions); + + if (updates.isNotEmpty) { + // Intentionally not awaited: updating map options asynchronously to avoid blocking widget update. + unawaited(_updateOptions(updates)); + } + _maplibreMapOptions = newOptions; + } + + Future _updateOptions(Map updates) async { + if (updates.isEmpty) { + return; + } + final controller = await _controller.future; + await controller._updateMapOptions(updates); + } + + Future onPlatformViewCreated(int id) async { + final controller = MapLibreMapController( + maplibrePlatform: _maplibrePlatform, + initialCameraPosition: widget.initialCameraPosition, + onStyleLoadedCallback: () async { + if (_controller.isCompleted) { + widget.onStyleLoadedCallback?.call(); + } else { + await _controller.future + .then((_) => widget.onStyleLoadedCallback?.call()); + } + }, + onMapClick: widget.onMapClick, + onUserLocationUpdated: widget.onUserLocationUpdated, + onMapLongClick: widget.onMapLongClick, + onCameraTrackingDismissed: widget.onCameraTrackingDismissed, + onCameraTrackingChanged: widget.onCameraTrackingChanged, + onCameraMove: widget.onCameraMove, + onCameraIdle: widget.onCameraIdle, + onMapIdle: widget.onMapIdle, + annotationOrder: widget.annotationOrder, + annotationConsumeTapEvents: widget.annotationConsumeTapEvents, + ); + await _maplibrePlatform.initPlatform(id); + _mapController = controller; + _controller.complete(controller); + widget.onMapCreated?.call(controller); + } +} + +/// Configuration options for the MapLibreMap user interface. +/// +/// When used to change configuration, null values will be interpreted as +/// "do not change this configuration option". +class _MapLibreMapOptions { + _MapLibreMapOptions( + {this.compassEnabled, + this.cameraTargetBounds, + this.styleString, + this.minMaxZoomPreference, + required this.rotateGesturesEnabled, + required this.scrollGesturesEnabled, + required this.tiltGesturesEnabled, + required this.zoomGesturesEnabled, + required this.doubleClickZoomEnabled, + this.trackCameraPosition, + this.myLocationEnabled, + this.myLocationTrackingMode, + this.myLocationRenderMode, + this.logoEnabled, + this.logoViewPosition, + this.logoViewMargins, + this.compassViewPosition, + this.compassViewMargins, + this.attributionButtonPosition, + this.attributionButtonMargins, + this.locationEnginePlatforms, + this.foregroundLoadColor, + this.translucentTextureSurface}); + + _MapLibreMapOptions.fromWidget(MapLibreMap map) + : this( + locationEnginePlatforms: map.locationEnginePlatforms, + compassEnabled: map.compassEnabled, + cameraTargetBounds: map.cameraTargetBounds, + styleString: map.styleString, + minMaxZoomPreference: map.minMaxZoomPreference, + rotateGesturesEnabled: map.rotateGesturesEnabled, + scrollGesturesEnabled: map.scrollGesturesEnabled, + tiltGesturesEnabled: map.tiltGesturesEnabled, + trackCameraPosition: map.trackCameraPosition, + zoomGesturesEnabled: map.zoomGesturesEnabled, + doubleClickZoomEnabled: + map.doubleClickZoomEnabled ?? map.zoomGesturesEnabled, + myLocationEnabled: map.myLocationEnabled, + myLocationTrackingMode: map.myLocationTrackingMode, + myLocationRenderMode: map.myLocationRenderMode, + logoEnabled: map.logoEnabled, + logoViewPosition: map.logoViewPosition, + logoViewMargins: map.logoViewMargins, + compassViewPosition: map.compassViewPosition, + compassViewMargins: map.compassViewMargins, + attributionButtonPosition: map.attributionButtonPosition, + attributionButtonMargins: map.attributionButtonMargins, + foregroundLoadColor: map.foregroundLoadColor, + translucentTextureSurface: map.translucentTextureSurface, + ); + + final bool? compassEnabled; + + final CameraTargetBounds? cameraTargetBounds; + + final String? styleString; + + final MinMaxZoomPreference? minMaxZoomPreference; + + final bool rotateGesturesEnabled; + + final bool scrollGesturesEnabled; + + final bool tiltGesturesEnabled; + + final bool zoomGesturesEnabled; + + final bool doubleClickZoomEnabled; + + final bool? trackCameraPosition; + + final bool? myLocationEnabled; + + final MyLocationTrackingMode? myLocationTrackingMode; + + final MyLocationRenderMode? myLocationRenderMode; + + final bool? logoEnabled; + + final LogoViewPosition? logoViewPosition; + + final Point? logoViewMargins; + + final CompassViewPosition? compassViewPosition; + + final Point? compassViewMargins; + + final AttributionButtonPosition? attributionButtonPosition; + + final Point? attributionButtonMargins; + + final LocationEnginePlatforms? locationEnginePlatforms; + + final Color? foregroundLoadColor; + + final bool? translucentTextureSurface; + + final _gestureGroup = { + 'rotateGesturesEnabled', + 'scrollGesturesEnabled', + 'tiltGesturesEnabled', + 'zoomGesturesEnabled', + 'doubleClickZoomEnabled' + }; + + Map toMap() { + final optionsMap = {}; + + void addIfNonNull(String fieldName, dynamic value) { + if (value != null) { + optionsMap[fieldName] = value; + } + } + + List? pointToArray(Point? fieldName) { + if (fieldName != null) { + return [fieldName.x, fieldName.y]; + } + + return null; + } + + addIfNonNull('compassEnabled', compassEnabled); + addIfNonNull('cameraTargetBounds', cameraTargetBounds?.toJson()); + addIfNonNull('styleString', styleString); + addIfNonNull('minMaxZoomPreference', minMaxZoomPreference?.toJson()); + + addIfNonNull('rotateGesturesEnabled', rotateGesturesEnabled); + addIfNonNull('scrollGesturesEnabled', scrollGesturesEnabled); + addIfNonNull('tiltGesturesEnabled', tiltGesturesEnabled); + addIfNonNull('zoomGesturesEnabled', zoomGesturesEnabled); + addIfNonNull('doubleClickZoomEnabled', doubleClickZoomEnabled); + + addIfNonNull('trackCameraPosition', trackCameraPosition); + addIfNonNull('myLocationEnabled', myLocationEnabled); + addIfNonNull('myLocationTrackingMode', myLocationTrackingMode?.index); + addIfNonNull('myLocationRenderMode', myLocationRenderMode?.index); + addIfNonNull('logoEnabled', logoEnabled); + addIfNonNull('logoViewPosition', logoViewPosition?.index); + addIfNonNull('logoViewMargins', pointToArray(logoViewMargins)); + addIfNonNull('compassViewPosition', compassViewPosition?.index); + addIfNonNull('compassViewMargins', pointToArray(compassViewMargins)); + addIfNonNull('attributionButtonPosition', attributionButtonPosition?.index); + addIfNonNull( + 'attributionButtonMargins', pointToArray(attributionButtonMargins)); + addIfNonNull('locationEngineProperties', locationEnginePlatforms?.toList()); + addIfNonNull('foregroundLoadColor', foregroundLoadColor?.toARGB32()); + addIfNonNull('translucentTextureSurface', translucentTextureSurface); + return optionsMap; + } + + Map updatesMap(_MapLibreMapOptions newOptions) { + final prevOptionsMap = toMap(); + final newOptionsMap = newOptions.toMap(); + + // if any gesture is updated also all other gestures have to the saved to + // the update + + final gesturesRequireUpdate = + _gestureGroup.any((key) => newOptionsMap[key] != prevOptionsMap[key]); + + return newOptionsMap + ..removeWhere((String key, dynamic value) { + if (_gestureGroup.contains(key)) return !gesturesRequireUpdate; + final oldValue = prevOptionsMap[key]; + if (oldValue is List && value is List) { + return listEquals(oldValue, value); + } + return oldValue == value; + }); + } +} diff --git a/third_party/maplibre_gl/lib/src/maplibre_styles.dart b/third_party/maplibre_gl/lib/src/maplibre_styles.dart new file mode 100644 index 00000000..c1f55cc6 --- /dev/null +++ b/third_party/maplibre_gl/lib/src/maplibre_styles.dart @@ -0,0 +1,15 @@ +part of '../maplibre_gl.dart'; + +@Deprecated('MaplibreStyles was renamed to MapLibreStyles.') +typedef MaplibreStyles = MapLibreStyles; + +/// MapLibre styles used mostly for demonstration. +abstract class MapLibreStyles { + /// A very simple MapLibre demo style that shows only countries with their + /// boundaries. + static const String demo = 'https://demotiles.maplibre.org/style.json'; + + /// The OpenFreeMap liberty style + static const String openfreemapLiberty = + 'https://tiles.openfreemap.org/styles/liberty'; +} diff --git a/third_party/maplibre_gl/lib/src/offline_region.dart b/third_party/maplibre_gl/lib/src/offline_region.dart new file mode 100644 index 00000000..e5e70cc7 --- /dev/null +++ b/third_party/maplibre_gl/lib/src/offline_region.dart @@ -0,0 +1,76 @@ +part of '../maplibre_gl.dart'; + +/// Description of region to be downloaded. Identifier will be generated when +/// the download is initiated. +class OfflineRegionDefinition { + const OfflineRegionDefinition({ + required this.bounds, + required this.mapStyleUrl, + required this.minZoom, + required this.maxZoom, + this.includeIdeographs = false, + }); + + final LatLngBounds bounds; + final String mapStyleUrl; + final double minZoom; + final double maxZoom; + final bool includeIdeographs; + + @override + String toString() => + "OfflineRegionDefinition, bounds = $bounds, mapStyleUrl = $mapStyleUrl, minZoom = $minZoom, maxZoom = $maxZoom"; + + Map toMap() { + final data = {}; + data['bounds'] = bounds.toList(); + data['mapStyleUrl'] = mapStyleUrl; + data['minZoom'] = minZoom; + data['maxZoom'] = maxZoom; + data['includeIdeographs'] = includeIdeographs; + return data; + } + + factory OfflineRegionDefinition.fromMap(Map map) { + return OfflineRegionDefinition( + bounds: _latLngBoundsFromList(map['bounds']), + mapStyleUrl: map['mapStyleUrl'], + // small integers may deserialize to Int + minZoom: map['minZoom'].toDouble(), + maxZoom: map['maxZoom'].toDouble(), + includeIdeographs: map['includeIdeographs'] ?? false, + ); + } + + static LatLngBounds _latLngBoundsFromList(List json) { + return LatLngBounds( + southwest: LatLng(json[0][0], json[0][1]), + northeast: LatLng(json[1][0], json[1][1]), + ); + } +} + +/// Description of a downloaded region including its identifier. +class OfflineRegion { + const OfflineRegion({ + required this.id, + required this.definition, + required this.metadata, + }); + + final int id; + final OfflineRegionDefinition definition; + final Map metadata; + + factory OfflineRegion.fromMap(Map json) { + return OfflineRegion( + id: json['id'], + definition: OfflineRegionDefinition.fromMap(json['definition']), + metadata: json['metadata'], + ); + } + + @override + String toString() => + "OfflineRegion, id = $id, definition = $definition, metadata = $metadata"; +} diff --git a/third_party/maplibre_gl/lib/src/util.dart b/third_party/maplibre_gl/lib/src/util.dart new file mode 100644 index 00000000..22945ea2 --- /dev/null +++ b/third_party/maplibre_gl/lib/src/util.dart @@ -0,0 +1,14 @@ +part of '../maplibre_gl.dart'; + +Map buildFeatureCollection( + List> features) { + return {"type": "FeatureCollection", "features": features}; +} + +final _random = Random(); +String getRandomString([int length = 10]) { + const charSet = + 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890'; + return String.fromCharCodes(Iterable.generate( + length, (_) => charSet.codeUnitAt(_random.nextInt(charSet.length)))); +} diff --git a/third_party/maplibre_gl/pubspec.yaml b/third_party/maplibre_gl/pubspec.yaml new file mode 100644 index 00000000..f5d99da7 --- /dev/null +++ b/third_party/maplibre_gl/pubspec.yaml @@ -0,0 +1,35 @@ +name: maplibre_gl +description: A Flutter plugin for integrating MapLibre Maps inside a Flutter application on Android, iOS and web platforms. +version: 0.25.0 +repository: https://github.com/maplibre/flutter-maplibre-gl +issue_tracker: https://github.com/maplibre/flutter-maplibre-gl/issues +resolution: workspace + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: '>=3.22.0' + +dependencies: + flutter: + sdk: flutter + maplibre_gl_platform_interface: ^0.25.0 + maplibre_gl_web: ^0.25.0 + +dev_dependencies: + very_good_analysis: ^10.0.0 + +flutter: + plugin: + platforms: + android: + package: org.maplibre.maplibregl + pluginClass: MapLibreMapsPlugin + ios: + pluginClass: MapLibreMapsPlugin + web: + default_package: maplibre_gl_web + +platforms: + android: + ios: + web: diff --git a/upload_ios.sh b/upload_ios.sh new file mode 100755 index 00000000..3f97a3f9 --- /dev/null +++ b/upload_ios.sh @@ -0,0 +1,68 @@ +#!/bin/bash + +# MeshMapper iOS upload script +# Uploads the Xcode archive produced by Build.sh (flutter build ipa) to +# App Store Connect using an ASC API key, via xcodebuild -exportArchive +# with ios/ExportOptionsUpload.plist (destination: upload). +# +# Auth: expects ASC_KEY_ID and ASC_ISSUER_ID (optionally ASC_KEY_PATH) in the +# environment or ~/.meshmapper_release.env. The .p8 key lives at +# ~/.appstoreconnect/private_keys/AuthKey_.p8 by default. +# +# Fallback if a future Xcode breaks this path (altool is deprecated but still ships): +# xcrun altool --upload-app -f -t ios --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID" +# +# The TestFlight "What to Test" text is not part of the archive and cannot be set +# here. Run ./set_whats_new.sh after this, once App Store Connect registers the build. +# +# Usage: ./upload_ios.sh [path/to/Runner.xcarchive] + +set -e + +cd "$(dirname "$0")" + +# Local release secrets (optional, never committed; lives outside the repo) +RELEASE_ENV="$HOME/.meshmapper_release.env" +if [ -f "$RELEASE_ENV" ]; then + source "$RELEASE_ENV" +fi + +if [ -z "$ASC_KEY_ID" ] || [ -z "$ASC_ISSUER_ID" ]; then + echo "Error: ASC_KEY_ID and ASC_ISSUER_ID must be set (env or $RELEASE_ENV)." + echo "Create an App Store Connect API key under Users and Access > Integrations." + exit 1 +fi + +ASC_KEY_PATH="${ASC_KEY_PATH:-$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8}" +if [ ! -f "$ASC_KEY_PATH" ]; then + echo "Error: API key file not found: $ASC_KEY_PATH" + echo "Download the .p8 from App Store Connect and place it there (chmod 600)." + exit 1 +fi + +# Archive path: argument > .last_build metadata > default flutter output location +ARCHIVE_PATH="$1" +if [ -z "$ARCHIVE_PATH" ] && [ -f .last_build ]; then + ARCHIVE_PATH=$(grep '^ARCHIVE_PATH=' .last_build | cut -d= -f2-) +fi +ARCHIVE_PATH="${ARCHIVE_PATH:-build/ios/archive/Runner.xcarchive}" +if [ ! -d "$ARCHIVE_PATH" ]; then + echo "Error: No archive at $ARCHIVE_PATH - run ./Build.sh first." + exit 1 +fi + +echo "Uploading $ARCHIVE_PATH to App Store Connect..." +xcodebuild -exportArchive \ + -archivePath "$ARCHIVE_PATH" \ + -exportOptionsPlist ios/ExportOptionsUpload.plist \ + -exportPath build/ios/upload \ + -allowProvisioningUpdates \ + -authenticationKeyPath "$ASC_KEY_PATH" \ + -authenticationKeyID "$ASC_KEY_ID" \ + -authenticationKeyIssuerID "$ASC_ISSUER_ID" + +echo "" +echo "Upload accepted. The build appears in TestFlight after Apple-side processing (~5-15 min)." +echo "" +echo "To set the TestFlight \"What to Test\" text for this build:" +echo " ./set_whats_new.sh --notes-file " diff --git a/web/index.html b/web/index.html index e0c14e7c..4af36e5d 100644 --- a/web/index.html +++ b/web/index.html @@ -27,6 +27,10 @@ // The value below is injected by flutter build, do not touch. const serviceWorkerVersion = null; + + + +