diff --git a/.build_version b/.build_version index 23aa839..f0bb29e 100644 --- a/.build_version +++ b/.build_version @@ -1 +1 @@ -1.2.2 +1.3.0 diff --git a/Build.sh b/Build.sh index f682816..a62e4ea 100755 --- a/Build.sh +++ b/Build.sh @@ -5,6 +5,17 @@ set -e # Exit on any error +# 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" + # Semver comparison: returns 0 (true) if $1 >= $2 version_gte() { local IFS=. @@ -154,7 +165,7 @@ echo "" # Build iOS IPA echo "[3/3] Building iOS IPA..." (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" +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 "" diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b7b1ad2..ae072d1 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -55,11 +55,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 +90,54 @@ 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. + +**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) @@ -281,7 +333,75 @@ 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 +### 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`). ### BLE Service UUIDs (MeshCore Companion Protocol) - Service: `6E400001-B5A3-F393-E0A9-E50E24DCCA9E` @@ -337,11 +457,39 @@ Key packages used in this project: - `flutter_blue_plus`: Mobile Bluetooth (Android/iOS) - `flutter_web_bluetooth`: Web Bluetooth (Chrome/Edge) - `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) + +### 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). ## Development Workflow Requirements @@ -506,6 +654,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 diff --git a/README.md b/README.md index dc09548..5f46dd0 100644 --- a/README.md +++ b/README.md @@ -97,4 +97,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 0000000..a542052 --- /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 9fd3c2c..3d54c64 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 3468904..38066d3 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,10 @@ 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") } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..e89a275 --- /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 688df15..ab81f27 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -14,6 +14,9 @@ + + + @@ -24,6 +27,7 @@ + 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 a11d769..b8be97d 100644 --- a/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -20,6 +20,11 @@ public static void registerWith(@NonNull FlutterEngine flutterEngine) { } 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) { @@ -55,6 +60,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/MainActivity.kt b/android/app/src/main/kotlin/net/meshmapper/app/MainActivity.kt index e7ff4c7..c96604d 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,103 @@ package net.meshmapper.app 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() { + private var usbService: MeshMapperUsbService? = null + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + usbService = MeshMapperUsbService(this) + usbService!!.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) + } + } + ) + } + + override fun onDestroy() { + usbService?.dispose() + super.onDestroy() + } +} 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 0000000..c2279c4 --- /dev/null +++ b/android/app/src/main/kotlin/net/meshmapper/app/MeshMapperUsbService.kt @@ -0,0 +1,557 @@ +// 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.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicLong + +class MeshMapperUsbService( + private val activity: FlutterActivity, +) { + 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 { + activity.getSystemService(Context.USB_SERVICE) as UsbManager + } + private val mainHandler = Handler(Looper.getMainLooper()) + private val usbIoExecutor: ExecutorService = Executors.newSingleThreadExecutor() + + @Volatile private var eventSink: EventChannel.EventSink? = null + @Volatile private var usbConnection: UsbDeviceConnection? = null + @Volatile private var usbInEndpoint: UsbEndpoint? = null + @Volatile private var usbOutEndpoint: UsbEndpoint? = null + @Volatile private var controlInterface: UsbInterface? = null + @Volatile private var dataInterface: UsbInterface? = null + private var readThread: Thread? = null + @Volatile private var isReading = false + @Volatile private var connectedDeviceName: String? = null + + private 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 { + activity.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) { + activity.registerReceiver(permissionReceiver, filter, Context.RECEIVER_EXPORTED) + } else { + @Suppress("DEPRECATION") + activity.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( + activity, + 0, + Intent(USB_PERMISSION_ACTION).setPackage(activity.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/assets/device-models.json b/assets/device-models.json index 49cb9e6..22f5a6b 100644 --- a/assets/device-models.json +++ b/assets/device-models.json @@ -299,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_offline_upload.dart b/bin/test_offline_upload.dart new file mode 100644 index 0000000..89ba5d4 --- /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/ios/ExportOptions.plist b/ios/ExportOptions.plist new file mode 100644 index 0000000..eb3a79e --- /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/Flutter/ephemeral/flutter_lldb_helper.py b/ios/Flutter/ephemeral/flutter_lldb_helper.py deleted file mode 100644 index a88caf9..0000000 --- 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 e3ba6fb..0000000 --- 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/Podfile b/ios/Podfile index c84fc8a..22d33ba 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -39,27 +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| - config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ - '$(inherited)', - 'PERMISSION_EVENTS=0', - 'PERMISSION_EVENTS_FULL_ACCESS=0', - 'PERMISSION_REMINDERS=0', - 'PERMISSION_CONTACTS=0', - 'PERMISSION_CAMERA=0', - 'PERMISSION_MICROPHONE=0', - 'PERMISSION_SPEECH_RECOGNIZER=0', - 'PERMISSION_PHOTOS=0', - 'PERMISSION_LOCATION=1', - 'PERMISSION_NOTIFICATIONS=1', - 'PERMISSION_MEDIA_LIBRARY=0', - 'PERMISSION_SENSORS=0', - 'PERMISSION_BLUETOOTH=0', - 'PERMISSION_APP_TRACKING_TRANSPARENCY=0', - 'PERMISSION_CRITICAL_ALERTS=0', - 'PERMISSION_ASSISTANT=0', - ] + # 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 21b9577..a367b48 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - disk_space_plus (0.0.1): + - Flutter - Flutter (1.0.0) - flutter_background_service_ios (0.0.3): - Flutter @@ -8,12 +10,15 @@ PODS: - Flutter DEPENDENCIES: + - 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_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) EXTERNAL SOURCES: + disk_space_plus: + :path: ".symlinks/plugins/disk_space_plus/ios" Flutter: :path: Flutter flutter_background_service_ios: @@ -24,11 +29,12 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/permission_handler_apple/ios" SPEC CHECKSUMS: + disk_space_plus: a36391fb5f732dbdd29628b0bac5a1acbd43aaef Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d -PODFILE CHECKSUM: 5f6d31cc7a922ccb43b951411657266fcae3377c +PODFILE CHECKSUM: 1b71fceb7f7c8618409f0f855f3558d90a1d2b33 COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4d7193e..a28342f 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -27,6 +27,15 @@ "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", diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index c3fedb2..95d6e55 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,7 +1,7 @@ + version = "1.7"> diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4d7193e..a28342f 100644 --- a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -27,6 +27,15 @@ "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", diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 0504495..34f4799 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,9 +1,73 @@ import Flutter +import MapLibre import UIKit import flutter_background_service_ios +/// URLProtocol that fails fast for MapLibre tile/style/glyph/sprite requests +/// while iOS offline-map mode is engaged. MapLibre-iOS reacts to the failure +/// by serving whatever it has in its internal tile cache (downloaded offline +/// regions + opportunistically cached tiles) and renders everything else as +/// the style's background layer, which matches Android's setOffline(true) +/// behavior closely enough for the "Use Downloaded Tiles Only" toggle. +/// +/// Hosts are kept in sync with the map style URLs in map_widget.dart's +/// MapStyleExtension.styleUrl (tiles.openfreemap.org) and the inline satellite +/// style JSON (server.arcgisonline.com). The coverage overlay uses per-zone +/// subdomains of meshmapper.net (e.g. `on.meshmapper.net`, `qc.meshmapper.net`) +/// so it's matched by suffix rather than an exact host entry. The Dart-side +/// add-time guard in _addCoverageOverlay covers the "toggle on at startup" +/// case; this suffix match covers the "toggle flipped while overlay is +/// already on the map" case (the Dart code doesn't remove the layer on flip). +class TileBlockingURLProtocol: URLProtocol { + static let blockedHosts: Set = [ + "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() + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -19,6 +83,119 @@ import flutter_background_service_ios // 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: 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/lib/main.dart b/lib/main.dart index 985fb78..37354b4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ import 'services/bluetooth/mobile_bluetooth.dart'; import 'services/bluetooth/web_bluetooth.dart'; import 'services/background_service.dart'; import 'services/debug_file_logger.dart'; +import 'services/offline_map_service.dart'; import 'utils/debug_logger_io.dart'; void main() async { @@ -69,6 +70,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 +91,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 } @@ -219,6 +222,9 @@ class MeshMapperApp extends StatelessWidget { ChangeNotifierProvider( create: (_) => AppStateProvider(bluetoothService: bluetoothService), ), + ChangeNotifierProvider( + create: (_) => OfflineMapService()..initialize(), + ), ], child: _ThemedApp(initialThemeMode: initialThemeMode), ); diff --git a/lib/models/api_queue_item.dart b/lib/models/api_queue_item.dart index 3a19735..472c4c8 100644 --- a/lib/models/api_queue_item.dart +++ b/lib/models/api_queue_item.dart @@ -56,6 +56,14 @@ class ApiQueueItem extends HiveObject { @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, @@ -68,6 +76,8 @@ class ApiQueueItem extends HiveObject { this.lastRetryAt, this.noiseFloor, this.power, + this.pingCounter, + this.wireTag, }); /// Create from TX ping @@ -80,6 +90,8 @@ class ApiQueueItem extends HiveObject { required bool externalAntenna, int? noiseFloor, double? power, + int? pingCounter, + String? wireTag, }) { return ApiQueueItem( type: 'TX', @@ -92,6 +104,8 @@ class ApiQueueItem extends HiveObject { externalAntenna: externalAntenna, noiseFloor: noiseFloor, power: power, + pingCounter: pingCounter, + wireTag: wireTag, ); } @@ -269,6 +283,10 @@ class ApiQueueItem extends HiveObject { 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, }; } diff --git a/lib/models/connection_state.dart b/lib/models/connection_state.dart index e807295..4b3a965 100644 --- a/lib/models/connection_state.dart +++ b/lib/models/connection_state.dart @@ -1,3 +1,6 @@ +/// Transport type for MeshCore companion connections. +enum TransportType { ble, tcp, usbSerial } + /// Connection status for Bluetooth devices enum ConnectionStatus { /// Not connected to any device @@ -25,8 +28,8 @@ 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, @@ -52,6 +55,9 @@ enum ConnectionStep { /// Step 9: Fully connected and ready connected, + /// Disconnecting (cleanup in progress) + disconnecting, + /// Error state error, } @@ -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/log_entry.dart b/lib/models/log_entry.dart index 2fe84af..0f5a632 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) @@ -36,7 +38,32 @@ class TxLogEntry { ? '${e.repeaterId}(${e.snr!.toStringAsFixed(2)})' : '${e.repeaterId}(null)') .join(','); - return '${timestamp.toIso8601String()},$latitude,$longitude,$power,$eventsStr'; + 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; } } @@ -81,6 +108,8 @@ class RxLogEntry { 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, @@ -91,6 +120,7 @@ class RxLogEntry { required this.header, required this.latitude, required this.longitude, + this.pathHops = const [], }); /// Get formatted timestamp (HH:MM:SS) @@ -120,9 +150,10 @@ 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'; } } diff --git a/lib/models/noise_floor_session.dart b/lib/models/noise_floor_session.dart index c2af353..8bdeb73 100644 --- a/lib/models/noise_floor_session.dart +++ b/lib/models/noise_floor_session.dart @@ -42,6 +42,9 @@ enum PingEventType { @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 @@ -61,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, }); } @@ -108,6 +116,7 @@ class PingEventMarker extends HiveObject { PingEventType.discFail => PingColors.discFail, PingEventType.traceSuccess => PingColors.traceSuccess, PingEventType.traceFail => PingColors.noResponse, + PingEventType.txMultiHopOnly => PingColors.rx, }; /// Get a display label for this event type @@ -119,6 +128,7 @@ class PingEventMarker extends HiveObject { PingEventType.discFail => 'DISC Fail', PingEventType.traceSuccess => 'Trace Success', PingEventType.traceFail => 'Trace Fail', + PingEventType.txMultiHopOnly => 'TX Multi-hop', }; } diff --git a/lib/models/ping_data.dart b/lib/models/ping_data.dart index 9d7e105..09defe6 100644 --- a/lib/models/ping_data.dart +++ b/lib/models/ping_data.dart @@ -87,6 +87,10 @@ class RxPing { @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, @@ -94,6 +98,7 @@ class RxPing { required this.timestamp, required this.snr, required this.rssi, + this.pathHops = const [], }); Map toApiJson() { @@ -115,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, }); } diff --git a/lib/models/remembered_device.dart b/lib/models/remembered_device.dart index 2bf9236..426502c 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 bbf76bd..8d5a620 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 11c04a7..2090262 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -70,9 +70,18 @@ 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; @@ -97,6 +106,17 @@ class UserPreferences { /// 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; @@ -129,7 +149,7 @@ 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', @@ -139,7 +159,9 @@ class UserPreferences { 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, @@ -148,6 +170,8 @@ class UserPreferences { this.gpsMarkerStyle = 'arrow', this.colorVisionType = 'none', this.mapTilesEnabled = true, + this.coverageOverlayOpacity = 0.7, + this.coverageGridSize = 300, this.disconnectAlertEnabled = false, this.customApiEnabled = false, this.customApiUrl, @@ -172,7 +196,7 @@ 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', + mapStyle: (json['mapStyle'] as String?) ?? 'liberty', closeAppAfterDisconnect: (json['closeAppAfterDisconnect'] as bool?) ?? false, themeMode: (json['themeMode'] as String?) ?? 'dark', @@ -183,7 +207,9 @@ class UserPreferences { 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, @@ -193,6 +219,12 @@ class UserPreferences { 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, @@ -238,7 +270,9 @@ class UserPreferences { 'mapRotationLocked': mapRotationLocked, 'disableRssiFilter': disableRssiFilter, 'anonymousMode': anonymousMode, + 'broadcastCoords': broadcastCoords, 'discDropEnabled': discDropEnabled, + 'floodTrafficEnabled': floodTrafficEnabled, 'deleteChannelOnDisconnect': deleteChannelOnDisconnect, 'minPingDistanceMeters': minPingDistanceMeters, 'autoStopAfterIdle': autoStopAfterIdle, @@ -247,6 +281,8 @@ class UserPreferences { 'gpsMarkerStyle': gpsMarkerStyle, 'colorVisionType': colorVisionType, 'mapTilesEnabled': mapTilesEnabled, + 'coverageOverlayOpacity': coverageOverlayOpacity, + 'coverageGridSize': coverageGridSize, 'disconnectAlertEnabled': disconnectAlertEnabled, 'customApiEnabled': customApiEnabled, 'customApiUrl': customApiUrl, @@ -281,7 +317,9 @@ class UserPreferences { bool? mapRotationLocked, bool? disableRssiFilter, bool? anonymousMode, + bool? broadcastCoords, bool? discDropEnabled, + bool? floodTrafficEnabled, bool? deleteChannelOnDisconnect, int? minPingDistanceMeters, bool? autoStopAfterIdle, @@ -290,6 +328,8 @@ class UserPreferences { String? gpsMarkerStyle, String? colorVisionType, bool? mapTilesEnabled, + double? coverageOverlayOpacity, + int? coverageGridSize, bool? disconnectAlertEnabled, bool? customApiEnabled, String? customApiUrl, @@ -323,7 +363,9 @@ 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: @@ -334,6 +376,9 @@ class UserPreferences { 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, @@ -397,7 +442,9 @@ class UserPreferences { other.mapRotationLocked == mapRotationLocked && other.disableRssiFilter == disableRssiFilter && other.anonymousMode == anonymousMode && + other.broadcastCoords == broadcastCoords && other.discDropEnabled == discDropEnabled && + other.floodTrafficEnabled == floodTrafficEnabled && other.deleteChannelOnDisconnect == deleteChannelOnDisconnect && other.minPingDistanceMeters == minPingDistanceMeters && other.autoStopAfterIdle == autoStopAfterIdle && @@ -406,6 +453,7 @@ class UserPreferences { other.gpsMarkerStyle == gpsMarkerStyle && other.colorVisionType == colorVisionType && other.mapTilesEnabled == mapTilesEnabled && + other.coverageOverlayOpacity == coverageOverlayOpacity && other.disconnectAlertEnabled == disconnectAlertEnabled && other.customApiEnabled == customApiEnabled && other.customApiUrl == customApiUrl && @@ -439,7 +487,9 @@ class UserPreferences { mapRotationLocked, disableRssiFilter, anonymousMode, + broadcastCoords, discDropEnabled, + floodTrafficEnabled, deleteChannelOnDisconnect, minPingDistanceMeters, autoStopAfterIdle, @@ -448,6 +498,7 @@ class UserPreferences { gpsMarkerStyle, colorVisionType, mapTilesEnabled, + coverageOverlayOpacity, disconnectAlertEnabled, customApiEnabled, customApiUrl, diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 4a7db8a..ae3f558 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1,5 +1,7 @@ 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; @@ -8,6 +10,7 @@ import 'package:flutter/widgets.dart' import 'package:geolocator/geolocator.dart'; import 'package:hive_flutter/hive_flutter.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'; @@ -19,12 +22,16 @@ 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/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'; @@ -40,6 +47,7 @@ import '../services/ping_service.dart'; import '../services/countdown_timer_service.dart'; import '../services/custom_api_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'; @@ -73,9 +81,12 @@ enum OfflineUploadResult { /// 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, @@ -84,6 +95,9 @@ enum OfflineUploadResult { /// GPS position required but not available gpsRequired, + + /// Zone is disabled server-side + zoneDisabled, } /// Main application state provider @@ -109,6 +123,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { late final RxWindowTimer _rxWindowTimer; late final DiscoveryWindowTimer _discoveryWindowTimer; // Discovery listening window (Passive Mode) + late final Listenable _timerListenable; MeshCoreConnection? _meshCoreConnection; PingService? _pingService; UnifiedRxHandler? _unifiedRxHandler; @@ -118,6 +133,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 = ''; @@ -151,8 +171,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 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 @@ -170,6 +191,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { DateTime? _idleAutoStopReference; static const Duration _autoStopIdleTimeout = Duration(minutes: 30); 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; @@ -212,6 +235,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 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 = {}; @@ -229,6 +256,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Remembered device for quick reconnection (mobile only) RememberedDevice? _rememberedDevice; + // 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; List _debugLogFiles = []; @@ -261,9 +295,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; @@ -272,6 +319,9 @@ 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; bool _isAutoReconnecting = false; @@ -318,13 +368,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 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 = []; 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 = []; @@ -346,6 +402,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 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; @@ -362,6 +422,10 @@ 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'); } else if (state == AppLifecycleState.paused) { debugLog('[APP] App paused (backgrounded)'); // Save offline pings immediately on pause to prevent data loss if OS kills app @@ -371,12 +435,92 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + /// 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; + TransportType get selectedTransport => _selectedTransport; ConnectionStatus get connectionStatus => _connectionStatus; ConnectionStep get connectionStep => _connectionStep; String? get connectionError => _connectionError; @@ -392,11 +536,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 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 isAutoPingStarting => _autoPingStarting; bool get isPingInProgress => _pingService?.pingInProgress ?? false; // True during entire ping + RX window (for auto pings) @@ -498,7 +648,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 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; @@ -518,7 +669,208 @@ 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?; @@ -546,6 +898,9 @@ 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; int get reconnectAttempt => _reconnectAttempt; @@ -562,9 +917,71 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 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. Returns `[]` + /// when there is no zone or on failure. The caller aggregates these into the + /// repeater's BIDIR/TX/RX/DISC/DEAD totals + max range. + Future>> fetchRepeaterCoveragePoints({ + required String prefix, + }) { + final zone = zoneCode; + if (zone == null || zone.isEmpty) { + return Future.value(const >[]); + } + 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); @@ -576,6 +993,10 @@ 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; @@ -596,6 +1017,24 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { bool get enforceDiscDrop => _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; + + /// One-shot flag: true when the user had flood traffic enabled and the + /// region forced it off on auth/zone-change. UI shows a dialog, then calls + /// [clearFloodDisabledAlert]. + bool _floodDisabledAlertPending = false; + bool get floodDisabledAlertPending => _floodDisabledAlertPending; + void clearFloodDisabledAlert() { + if (!_floodDisabledAlertPending) return; + _floodDisabledAlertPending = false; + notifyListeners(); + } int get minModeInterval => _apiService.minModeInterval; bool get enforceHopBytes => _apiService.enforceHopBytes; int get hopBytes => _hopBytes; @@ -631,6 +1070,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { RxWindowTimer get rxWindowTimer => _rxWindowTimer; DiscoveryWindowTimer get discoveryWindowTimer => _discoveryWindowTimer; // Discovery listening window (Passive Mode) + Listenable get timerListenable => _timerListenable; // ============================================ // Initialization @@ -676,13 +1116,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _offlineSessionService = OfflineSessionService(); _deviceModelService = DeviceModelService(); - // 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); + // 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, + ]); // Initialize debug logging (enabled by default, respects user preference) await _initDebugLogs(); @@ -723,7 +1170,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } }; - _apiQueueService.onUploadSuccess = (uploadedCount) { + _apiQueueService.onUploadSuccess = (uploadedCount, uploadedItems) { _pingStats = _pingStats.copyWith( successfulUploads: _pingStats.successfulUploads + uploadedCount, ); @@ -731,15 +1178,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { '[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 @@ -762,6 +1214,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _loadPreferences(); await _loadDeviceAntennaPreferences(); await _loadDevicePowerOverrides(); + await _loadDeviceRealNames(); // Load last known GPS position for map centering await _loadLastPosition(); @@ -858,9 +1311,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _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) @@ -1083,262 +1546,400 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Connection // ============================================ - /// Connect to a discovered device - Future connectToDevice(DiscoveredDevice device) async { - try { - _connectionError = null; - _isAuthError = false; - _isNetworkError = false; - - // Clean up any previous connection first - if (_meshCoreConnection != null) { - debugLog('[APP] Disposing previous MeshCoreConnection'); - _meshCoreConnection!.dispose(); - _meshCoreConnection = null; + /// 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' + }; } - // ALWAYS START FRESH - clear any stale pings before connecting - await _apiQueueService.clearBeforeConnect(); - - // Create MeshCore connection - debugLog('[APP] Creating new MeshCoreConnection'); - _meshCoreConnection = MeshCoreConnection(bluetooth: _bluetoothService); - - // 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' - }; + // 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; } - - // 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 - } + 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 - 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' - }; + // 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' + }; + } - // ============================================================ - // STAGE 1: Try existing public_key authentication - // ============================================================ - debugLog( - '[APP] Stage 1: Attempting auth with public_key: ${publicKey.substring(0, 16)}...'); + // 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', - lat: _currentPosition?.latitude, - lon: _currentPosition?.longitude, - accuracyMeters: _currentPosition?.accuracy, - ); + 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, + ); - // 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', - }; - } + 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', + }; + } - // Check if Stage 1 succeeded - if (result != null && result['success'] == true) { - debugLog('[APP] Stage 1 succeeded: authenticated via public_key'); + 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; + } - // Store the auth type from response - if (result['type'] != null) { - _authType = result['type'] as String; - debugLog('[APP] Auth type: $_authType'); - notifyListeners(); - } + if (result == null) { + debugError('[APP] API unreachable - network error'); + return { + 'success': false, + 'reason': 'network_error', + 'message': 'Unable to reach the MeshMapper server', + }; + } - // Sync zone capacity display with auth result - _syncZoneCapacityFromAuth(result); + debugLog( + '[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); - return result; - } + 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?, + }; + } - // 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', - }; - } + // Stage 2: Auth failed, attempt registration via signed contact_uri + debugLog('[APP] Stage 2: Attempting registration via contact_uri...'); - debugLog( - '[APP] Stage 1 failed: ${result['message'] ?? 'Unknown error'}'); + 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' + }; + } - // 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?, - }; - } + 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, + ); - // ============================================================ - // STAGE 2: Auth failed, attempt registration via signed contact_uri - // ============================================================ - debugLog('[APP] Stage 2: Attempting registration via contact_uri...'); + 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', + }; + } - String? contactUri; + 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 { - debugLog('[APP] Requesting signed contact URI from device...'); - contactUri = await _meshCoreConnection!.exportContact(); - debugLog( - '[APP] Received contact URI: ${contactUri.substring(0, 50)}...'); + 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) { - 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' - }; - } - - // 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 (registerResult == null) { - debugError('[APP] Stage 2 failed: network error (API unreachable)'); - return { - 'success': false, - 'reason': 'network_error', - 'message': 'Unable to reach the MeshMapper server', - }; + debugWarn('[APP] Could not query device time: $e'); } + } - 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', - }; - } + return { + 'success': false, + 'reason': serverReason, + 'message': serverMessage ?? 'Registration rejected by server', + }; + } - // Registration successful - response contains full auth data directly - debugLog('[APP] Stage 2 succeeded: registered and authenticated'); + 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; + }; + } - // Store the auth type from response - if (registerResult['type'] != null) { - _authType = registerResult['type'] as String; - debugLog('[APP] Auth type: $_authType'); - notifyListeners(); - } + /// Handle connection errors — shared by all transport connection methods. + Future _handleConnectionError(Object e) async { + debugError('[APP] Connection failed: $e'); - // Sync zone capacity display with auth result - _syncZoneCapacityFromAuth(registerResult); + try { + await _meshCoreConnection?.deleteWardrivingChannelEarly(); + } catch (channelError) { + debugError('[APP] Cleanup channel delete failed: $channelError'); + } - return registerResult; - }; + 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 { - // Offline mode: skip API auth - _meshCoreConnection!.onRequestAuth = null; - debugLog('[APP] Offline mode: skipping API auth'); + _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(); + } - // 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; + /// 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( - '[APP] Device public key stored: ${_devicePublicKey?.substring(0, 16) ?? 'null'}...'); + '[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; + _connectionStep = ConnectionStep.transportConnecting; + _connectionError = null; + _isAuthError = false; + _isNetworkError = false; + notifyListeners(); + 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}'); + await _bluetoothService.connect(device.id); + _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 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-', ''); + } + // Cascade guard: never persist "Anonymous" as the last connected device + if (lastDeviceName == 'Anonymous' && _devicePublicKey != null) { + lastDeviceName = + _deviceRealNames[_devicePublicKey!] ?? lastDeviceName; } - if (deviceName != null && - deviceName.isNotEmpty && + if (lastDeviceName != null && + lastDeviceName.isNotEmpty && _devicePublicKey != null) { - _saveLastConnectedDevice(deviceName, _devicePublicKey!); + _saveLastConnectedDevice(lastDeviceName, _devicePublicKey!); } // In offline mode, fetch signed contact URI for later registration during upload @@ -1354,683 +1955,1038 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); }); - // Listen for noise floor updates + // Listen for noise floor updates — only rebuild UI when value changes _noiseFloorSubscription = _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { - _currentNoiseFloor = noiseFloor; - // Record sample to current noise floor session (if active) _recordNoiseFloorSample(noiseFloor); - notifyListeners(); + if (noiseFloor != _currentNoiseFloor) { + _currentNoiseFloor = noiseFloor; + notifyListeners(); + } }); - // Listen for battery updates + // Listen for battery updates — only rebuild UI when value changes _batterySubscription = _meshCoreConnection!.batteryStream.listen((batteryPercent) { - _currentBatteryPercent = batteryPercent; - notifyListeners(); + if (batteryPercent != _currentBatteryPercent) { + _currentBatteryPercent = batteryPercent; + notifyListeners(); + } }); - // Execute connection workflow + // Execute connection workflow (transport already connected above) 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 - powerLevelSet: false, // Clear stale manual flag from previous session - ); - notifyListeners(); - debugLog( - '[MODEL] Device recognized: ${device.shortName} - reporting ${device.power}W in API calls'); + await _postConnectionSetup(connectionResult, device); + _isConnecting = false; + } catch (e) { + await _handleConnectionError(e); + } + } + + /// Set the selected transport type for the connection screen. + void setSelectedTransport(TransportType type) { + _selectedTransport = type; + notifyListeners(); + } + + /// 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; + _connectionStep = ConnectionStep.transportConnecting; + _connectionError = null; + _isAuthError = false; + _isNetworkError = false; + notifyListeners(); + try { + if (_meshCoreConnection != null) { + debugLog('[APP] Disposing previous MeshCoreConnection'); + _meshCoreConnection!.dispose(); + _meshCoreConnection = null; } - // Note: API session acquisition is now handled by the auth callback - // during connection workflow Step 6 (onRequestAuth) + await _apiQueueService.clearBeforeConnect(); - // Create unified RX handler - await _createUnifiedRxHandler(); + final tcpService = TcpService(host: host, port: port); + debugLog('[APP] Connecting TCP transport to $host:$port'); + await tcpService.openConnection(); + _activeTransport = tcpService; + _setupTransportDisconnectListener(tcpService); - // 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'); + debugLog('[APP] Creating new MeshCoreConnection (TCP)'); + _meshCoreConnection = MeshCoreConnection(transport: tcpService); - // 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, - ); + if (!_preferences.offlineMode) { + _meshCoreConnection!.onRequestAuth = _createAuthCallback(); + } else { + _meshCoreConnection!.onRequestAuth = null; + debugLog('[APP] Offline mode: skipping API auth'); + } + + _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 (_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'); + }); + } } - 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(', ')}'); + notifyListeners(); + }); + + _noiseFloorSubscription = + _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { + _recordNoiseFloorSample(noiseFloor); + if (noiseFloor != _currentNoiseFloor) { + _currentNoiseFloor = noiseFloor; + notifyListeners(); + } + }); + + _batterySubscription = + _meshCoreConnection!.batteryStream.listen((batteryPercent) { + if (batteryPercent != _currentBatteryPercent) { + _currentBatteryPercent = batteryPercent; + notifyListeners(); + } + }); + + final connectionResult = await _meshCoreConnection!.connect( + _deviceModelService.models, + ); + + final device = DiscoveredDevice( + id: '$host:$port', + name: 'TCP $host:$port', + ); + await _postConnectionSetup(connectionResult, device, + tcpHost: host, tcpPort: port); + + await TcpService.saveConnection(host, port, displayDeviceName ?? ''); + _isConnecting = false; + } catch (e) { + await _handleConnectionError(e); + if (_activeTransport != null && _activeTransport != _bluetoothService) { + _activeTransport!.dispose(); } + _activeTransport = null; + _transportConnectionSubscription?.cancel(); + } + } - // 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'); + /// 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; + _connectionStep = ConnectionStep.transportConnecting; + _connectionError = null; + _isAuthError = false; + _isNetworkError = false; + notifyListeners(); + try { + if (_meshCoreConnection != null) { + debugLog('[APP] Disposing previous MeshCoreConnection'); + _meshCoreConnection!.dispose(); + _meshCoreConnection = null; + } + + if (_activeTransport != null && _activeTransport != _bluetoothService) { + _activeTransport!.dispose(); + } + _activeTransport = null; + _transportConnectionSubscription?.cancel(); + + await _apiQueueService.clearBeforeConnect(); + + 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); + + debugLog('[APP] Creating new MeshCoreConnection (USB Serial)'); + _meshCoreConnection = MeshCoreConnection(transport: serialService); + + if (!_preferences.offlineMode) { + _meshCoreConnection!.onRequestAuth = _createAuthCallback(); } else { - _scope = null; - debugLog('[CONN] No regional scope — using unscoped flood'); + _meshCoreConnection!.onRequestAuth = null; + debugLog('[APP] Offline mode: skipping API auth'); } - // 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'); + _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 (_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(); + }); + + _noiseFloorSubscription = + _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { + _recordNoiseFloorSample(noiseFloor); + if (noiseFloor != _currentNoiseFloor) { + _currentNoiseFloor = noiseFloor; + notifyListeners(); + } + }); + + _batterySubscription = + _meshCoreConnection!.batteryStream.listen((batteryPercent) { + if (batteryPercent != _currentBatteryPercent) { + _currentBatteryPercent = batteryPercent; + notifyListeners(); + } + }); + + 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(); + } + } - // 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'); + /// 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; + _connectionStep = ConnectionStep.transportConnecting; + _connectionError = null; + _isAuthError = false; + _isNetworkError = false; + notifyListeners(); + try { + if (_meshCoreConnection != null) { + debugLog('[APP] Disposing previous MeshCoreConnection'); + _meshCoreConnection!.dispose(); + _meshCoreConnection = null; } - // 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'); + 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 { + _meshCoreConnection!.onRequestAuth = null; + debugLog('[APP] Offline mode: skipping API auth'); } - // Configure multi-byte path hash mode on radio - await _configurePathHashMode(); + _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'}...'); - // 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, - hopBytes: effectiveHopBytes, - traceHopBytes: _traceHopBytes, - shouldIgnoreRepeater: (String repeaterId) { - final prefs = _preferences; - if (prefs.ignoreCarpeater && prefs.ignoreRepeaterId != null) { - return PacketValidator.isCarpeaterIdMatch( - repeaterId, prefs.ignoreRepeaterId!); + var lastDeviceName = _isAnonymousRenamed + ? _originalDeviceName + : (_meshCoreConnection!.selfInfo?.name ?? connectedDeviceName); + if (lastDeviceName != null) { + lastDeviceName = lastDeviceName.replaceFirst('MeshCore-', ''); } - return false; - }, + if (lastDeviceName == 'Anonymous' && _devicePublicKey != null) { + lastDeviceName = + _deviceRealNames[_devicePublicKey!] ?? lastDeviceName; + } + if (lastDeviceName != null && + lastDeviceName.isNotEmpty && + _devicePublicKey != null) { + _saveLastConnectedDevice(lastDeviceName, _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(); + }); + + _noiseFloorSubscription = + _meshCoreConnection!.noiseFloorStream.listen((noiseFloor) { + _recordNoiseFloorSample(noiseFloor); + if (noiseFloor != _currentNoiseFloor) { + _currentNoiseFloor = noiseFloor; + notifyListeners(); + } + }); + + _batterySubscription = + _meshCoreConnection!.batteryStream.listen((batteryPercent) { + if (batteryPercent != _currentBatteryPercent) { + _currentBatteryPercent = batteryPercent; + notifyListeners(); + } + }); + + final connectionResult = await _meshCoreConnection!.connect( + _deviceModelService.models, ); - // Wire UnifiedRxHandler so trace payloads route to TraceTracker - _pingService!.unifiedRxHandler = _unifiedRxHandler; + 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(); + } + } - // Set validation callbacks - _pingService!.checkExternalAntennaConfigured = () { - // External antenna must be explicitly set (yes or no) before pinging - return _preferences.externalAntennaSet; - }; + /// 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 { + 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'); + } - _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; - }; + await _createUnifiedRxHandler(); - // Get external antenna value for API payloads - _pingService!.getExternalAntenna = () => _preferences.externalAntenna; + final apiChannels = _apiService.channels; + await ChannelService.setRegionalChannels(apiChannels); + _regionalChannels = ChannelService.getRegionalChannelNames(); + debugLog('[APP] Regional channels configured: $_regionalChannels'); - // Get power level from preferences (includes per-device overrides and manual selection) - _pingService!.getPowerLevel = () => _preferences.powerLevel; + 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( + '[APP] PacketValidator updated with ${allowedChannels.length} channels: ' + '${allowedChannelsData.values.map((c) => c.channelName).join(', ')}'); + } + + 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'); + } - // Check if TX is allowed by API (zone capacity) - _pingService!.checkTxAllowed = () => txAllowed; + // Snapshot user's preferences before zone admin overrides (single baseline) + _userOriginalAutoPingInterval = _preferences.autoPingInterval; + _userOriginalHybridMode = _preferences.hybridModeEnabled; + _userOriginalDiscDrop = _preferences.discDropEnabled; + _userOriginalFloodTraffic = _preferences.floodTrafficEnabled; - // Check if discovery drop is enabled - _pingService!.getDiscDropEnabled = () => discDropEnabled; + if (_apiService.enforceHybrid && !_preferences.hybridModeEnabled) { + _preferences = _preferences.copyWith(hybridModeEnabled: true); + debugLog('[CONN] Hybrid mode force-enabled by regional admin'); + } - _pingService!.onTxPing = (ping) { - _txPings.add(ping); - if (_txPings.length > _maxMapPins) _txPings.removeAt(0); + if (_apiService.enforceDiscDrop && !_preferences.discDropEnabled) { + _preferences = _preferences.copyWith(discDropEnabled: true); + debugLog('[CONN] Discovery drop force-enabled by regional admin'); + } - // 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); + final wasFloodEnabledByUser = _preferences.floodTrafficEnabled; + 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 (wasFloodEnabledByUser && _apiService.floodDisabled) { + _floodDisabledAlertPending = true; + } - notifyListeners(); - }; + 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!.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); - - // Update RX overlay slot with this RX observation - _updateRxOverlaySlot(ping.repeaterId, ping.snr); + _pingService!.unifiedRxHandler = _unifiedRxHandler; - notifyListeners(); - }; + _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) { + _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); + + _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); + + _updateRxOverlaySlot(ping.repeaterId, ping.snr); + _notifyMapThrottled(); + }; + + _pingService!.onStatsUpdated = (stats) { + _pingStats = stats.copyWith( + rxCount: _pingStats.rxCount, + successfulUploads: _pingStats.successfulUploads, + ); + notifyListeners(); - _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( + 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, - successfulUploads: _pingStats.successfulUploads, + queueSize: _queueSize, ); - notifyListeners(); + } + }; - // Update background service notification with current stats - 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, + _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, ); - } - }; - - // 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, - ); - - 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; - } - } - // 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)'); - - // Update top repeaters overlay with current TX echoes - _updateTopRepeaters( - existingEvents - .where((e) => e.snr != null) - .map((e) => - (repeaterId: e.repeaterId.toUpperCase(), snr: e.snr!)) - .toList(), - OverlayPingType.tx); - - debugLog('[APP] Calling notifyListeners() to update UI'); - notifyListeners(); - debugLog('[APP] notifyListeners() completed'); + if (isNew) { + existingEvents.add(newEvent); + _audioService.playReceiveSound(); } else { - debugLog( - '[APP] Timestamp mismatch: lastEntry=${lastEntry.timestamp}, txPing=${txPing.timestamp}, diff=${timeDiff}s'); + 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; + debugLog( + '[APP] Updated TxLogEntry with ${existingEvents.length} direct, ' + '${lastEntry.multiHopEvents.length} multi-hop events (real-time)'); + + _updateTopRepeaters( + existingEvents + .where((e) => e.snr != null) + .map((e) => + (repeaterId: e.repeaterId.toUpperCase(), snr: e.snr!)) + .toList(), + OverlayPingType.tx); + + debugLog('[APP] Calling notifyListeners() to update UI'); + _notifyMapThrottled(); + debugLog('[APP] notifyListeners() completed'); } else { - debugLog('[APP] WARNING: _txLogEntries is empty, cannot update'); + debugLog( + '[APP] Timestamp mismatch: lastEntry=${lastEntry.timestamp}, txPing=${txPing.timestamp}, diff=${timeDiff}s'); } - }; + } else { + debugLog('[APP] WARNING: _txLogEntries is empty, cannot update'); + } + }; + + _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, + ); - // Wire up ping progress callback for immediate UI refresh (e.g. "Sending..." on disc) - _pingService!.onPingProgressChanged = notifyListeners; - - // Wire up auto ping scheduled callback for countdown display - _pingService!.onAutoPingScheduled = (intervalMs, skipReason) { - _autoPingTimer.startWithSkipReason(intervalMs, skipReason); - - // Track idle time for auto-stop - if (skipReason != null) { - // Ping was skipped — check if idle too long - if (_preferences.autoStopAfterIdle && - _idleAutoStopReference != null) { - final elapsed = DateTime.now().difference(_idleAutoStopReference!); - if (elapsed >= _autoStopIdleTimeout) { - _triggerIdleAutoStop(); + 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; } } - } else { - // Successful ping — reset idle reference - _idleAutoStopReference = DateTime.now(); - } - }; - // Wire up discovery ping callback - fires immediately (like onTxPing) - _pingService!.onDiscPing = (entry) { - _addDiscLogEntry(entry); - }; + _txLogEntries[_txLogEntries.length - 1] = TxLogEntry( + timestamp: lastEntry.timestamp, + latitude: lastEntry.latitude, + longitude: lastEntry.longitude, + power: lastEntry.power, + events: lastEntry.events, + multiHopEvents: multiHopEvents, + ); - // 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(); + _notifyMapThrottled(); } + } + }; - // Update top repeaters overlay with all discovered nodes from this ping - _updateTopRepeaters( - discPing.discoveredNodes - .map((n) => - (repeaterId: n.repeaterId.toUpperCase(), snr: n.localSnr)) - .toList(), - OverlayPingType.disc); + _pingService!.onPingProgressChanged = notifyListeners; - notifyListeners(); - }; + _pingService!.onAutoPingScheduled = (intervalMs, skipReason) { + _autoPingTimer.startWithSkipReason(intervalMs, skipReason); - // 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(); + if (skipReason != null) { + if (_preferences.autoStopAfterIdle && + _idleAutoStopReference != null) { + final elapsed = + DateTime.now().difference(_idleAutoStopReference!); + if (elapsed >= _autoStopIdleTimeout) { + _triggerIdleAutoStop(); } } + } else { + _idleAutoStopReference = DateTime.now(); + } + }; - recordPingEvent( - success ? PingEventType.txSuccess : PingEventType.txFail, - 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!.onDiscPing = (entry) { + _addDiscLogEntry(entry); + }; - PingEventType eventType; - if (success) { - eventType = PingEventType.discSuccess; - } else if (discDropEnabled) { - eventType = PingEventType.txFail; - } else { - eventType = PingEventType.discFail; - } + _pingService!.onDiscNodeDiscovered = (discPing, nodeEntry, isNew) { + debugLog( + '[APP] Real-time disc node: ${nodeEntry.repeaterId}, isNew=$isNew'); + if (isNew) { + _audioService.playReceiveSound(); + } - recordPingEvent( - eventType, - latitude: lat, - longitude: lon, - repeaters: repeaters, - ); - }; + _updateTopRepeaters( + discPing.discoveredNodes + .map((n) => + (repeaterId: n.repeaterId.toUpperCase(), snr: n.localSnr)) + .toList(), + OverlayPingType.disc); - // Wire up trace ping callback (for log entry creation) - _pingService!.onTracePing = (entry) { - _addTraceLogEntry(entry); - }; + _notifyMapThrottled(); + }; - // Wire up trace window complete callback for noise floor graph - _pingService!.onTraceWindowComplete = (result) { - 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, - ) - ]; - // Update the log entry with success data - _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, - ); - notifyListeners(); - } + _pingService!.onTxWindowComplete = (directSuccess, multiHopEchoes) { + double? lat; + double? lon; + List? allRepeaters; + + 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]; } + } - recordPingEvent( - result != null && result.success - ? PingEventType.traceSuccess - : PingEventType.traceFail, - latitude: lat, - longitude: lon, - repeaters: repeaters, - ); - }; + final PingEventType eventType; + if (directSuccess) { + eventType = PingEventType.txSuccess; + } else if (multiHopEchoes.isNotEmpty) { + eventType = PingEventType.txMultiHopOnly; + } else { + eventType = PingEventType.txFail; + } - // 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); - }; + recordPingEvent( + eventType, + latitude: lat, + longitude: lon, + repeaters: allRepeaters, + ); + }; - // 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'); + _pingService!.onDiscoveryWindowComplete = (success) { + 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(); + } + } - // Stop TX echo tracking - _pingService!.stopEchoTracking(); - // Stop RX wardriving (flushes batches) - _rxLogger?.stopWardriving(trigger: 'pending_disable'); + PingEventType eventType; + if (success) { + eventType = PingEventType.discSuccess; + } else if (discDropEnabled) { + eventType = PingEventType.txFail; + } else { + eventType = PingEventType.discFail; + } - // Stop background service - await BackgroundServiceManager.stopService(); + recordPingEvent( + eventType, + latitude: lat, + longitude: lon, + repeaters: repeaters, + ); + }; - // Stop countdown timers - _autoPingTimer.stop(); - _rxWindowTimer.stop(); + _pingService!.onTracePing = (entry) { + _addTraceLogEntry(entry); + }; - // Save offline session if offline mode is enabled - if (_preferences.offlineMode) { - await _saveOfflineSession(); + _pingService!.onTraceWindowComplete = (result) { + 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, + ); + _notifyMapNow(); } + } - // End noise floor session - await _endNoiseFloorSession(); + recordPingEvent( + result != null && result.success + ? PingEventType.traceSuccess + : PingEventType.traceFail, + latitude: lat, + longitude: lon, + repeaters: repeaters, + ); + }; - // Disable heartbeat - _apiService.disableHeartbeat(); + _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); + }; - // Update local state - _autoPingEnabled = false; - _idleAutoStopReference = null; + _pingService!.onPendingDisableComplete = () async { + debugLog('[APP] Pending disable completed, cleaning up'); - debugLog('[APP] Pending disable cleanup complete, cooldown running'); - notifyListeners(); - }; + _pingService!.stopEchoTracking(); + _rxLogger?.stopWardriving(trigger: 'pending_disable'); - // 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'); - } - } - } + await BackgroundServiceManager.stopService(); - // 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, - ); - _antennaRestoredFromDevice = true; - _savePreferences(); - debugLog( - '[APP] Restored antenna preference for "$resolvedName": ${savedAntenna ? "external" : "device"}'); - notifyListeners(); - } + _autoPingTimer.stop(); + _rxWindowTimer.stop(); - // Restore per-device power override if previously saved - 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(); + if (_preferences.offlineMode) { + await _saveOfflineSession(); } - // 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'); - } + await _endNoiseFloorSession(); + _apiService.disableHeartbeat(); - // Track session zone for zone-to-zone transfer detection - _sessionZoneCode = zoneCode; + _autoPingEnabled = false; + _idleAutoStopReference = null; - // Start periodic zone refresh to keep slot counts current - if (!_preferences.offlineMode) { - _startZoneRefreshTimer(); - } + debugLog('[APP] Pending disable cleanup complete, cooldown running'); + notifyListeners(); + }; - // Enable heartbeat immediately on connection to keep server session alive - // Previously only enabled on auto-ping start, causing silent session expiry - if (!_preferences.offlineMode && _apiService.hasSession) { - _apiService.enableHeartbeat( - gpsProvider: () { - final pos = _gpsService.lastPosition; - if (pos == null) return null; - return (lat: pos.latitude, lon: pos.longitude); - }, - ); - debugLog('[HEARTBEAT] Enabled on connection'); + 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'); } + } + } - // Start 15-minute idle disconnect timer (cancelled by manual ping or auto-ping start) - _startIdleDisconnectTimer(); + 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(); + } + + 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(); + } + + 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 { - // No API session - offline mode or auth skipped - debugLog('[CONN] Connected without API session (offline mode)'); + debugLog('[CONN] Connected with limited access'); } - // Log ping validation status after connection - final validation = pingValidation; - if (validation != PingValidation.valid) { - debugLog('[CONN] Ping validation after connect: $validation'); - } - } catch (e) { - debugError('[APP] Connection failed: $e'); + _sessionZoneCode = zoneCode; - // 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'); + if (!_preferences.offlineMode) { + _startZoneRefreshTimer(); } - // Ensure BLE is disconnected on any connection failure - // (connection.dart should have done this, but be defensive) - 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; - _isNetworkError = reason == 'network_error'; - _connectionError = _getErrorMessage(reason, serverMessage); - } else { - _connectionError = 'Authentication failed'; - } - } else { - _isAuthError = false; - _isNetworkError = 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: ', ''); - } + if (!_preferences.offlineMode && _apiService.hasSession) { + _apiService.enableHeartbeat( + gpsProvider: () { + final pos = _gpsService.lastPosition; + if (pos == null) return null; + return (lat: pos.latitude, lon: pos.longitude); + }, + ); + debugLog('[HEARTBEAT] Enabled on connection'); } - _connectionStep = ConnectionStep.error; - notifyListeners(); + + _startIdleDisconnectTimer(); + } else { + debugLog('[CONN] Connected without API session (offline mode)'); + } + + final validation = pingValidation; + if (validation != PingValidation.valid) { + debugLog('[CONN] Ping validation after connect: $validation'); } } @@ -2085,6 +3041,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); @@ -2116,7 +3073,7 @@ 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'); @@ -2163,6 +3120,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 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}: ' @@ -2181,6 +3139,7 @@ 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); @@ -2205,6 +3164,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { header: entry.header, latitude: entry.lat, longitude: entry.lon, + pathHops: entry.displayHops, ); // Add to RX log entries @@ -2236,8 +3196,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 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'); @@ -2303,14 +3263,19 @@ 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; - // Map TX bytes to trace bytes (3-byte traces not possible, use 4) _traceHopBytes = deviceHopBytes == 3 ? 4 : deviceHopBytes; _pingService?.traceHopBytes = _traceHopBytes; debugLog( @@ -2321,19 +3286,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } 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 + _hopBytes = effective; _traceHopBytes = effective == 3 ? 4 : effective; _pingService?.traceHopBytes = _traceHopBytes; debugLog( - '[PATH] Set path hash mode: device was $deviceHopBytes-byte, now $effective-byte (trace: $_traceHopBytes-byte)'); + '[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) { @@ -2341,7 +3303,7 @@ 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'); @@ -2357,7 +3319,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } else { debugLog( - '[PATH] Path hash mode OK: device=$deviceHopBytes-byte, effective=$effective-byte'); + '[PATH] Path hash mode OK: radio=$currentRuntimeHopBytes-byte, effective=$effective-byte'); } } @@ -2466,6 +3428,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return; } _cancelPendingAutoPingRestore(); + _isConnecting = false; _connectionStep = ConnectionStep.disconnected; // Cancel any active zone grace period @@ -2474,9 +3437,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _zoneGraceSecondsRemaining = 0; _autoPingWasEnabledBeforeGrace = false; - // Stop heartbeat immediately on BLE disconnect _apiService.disableHeartbeat(); - debugLog('[CONN] Heartbeat disabled due to BLE disconnect'); + debugLog('[CONN] Heartbeat disabled due to disconnect'); // Stop zone refresh timer _stopZoneRefreshTimer(); @@ -2491,7 +3453,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } _autoPingEnabled = false; _idleAutoStopReference = null; - debugLog('[AUTO] Auto-ping disabled due to BLE disconnect'); + debugLog('[AUTO] Auto-ping disabled due to disconnect'); } // End noise floor session on BLE disconnect @@ -2515,7 +3477,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', @@ -2527,21 +3489,26 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } - // Reset anonymous mode state (BLE already gone, can't restore name) _isAnonymousRenamed = false; _originalDeviceName = null; - // Clear top-heard overlay _clearOverlayState(); - // Existing cleanup _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) { @@ -2640,6 +3607,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { '[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( @@ -2696,8 +3674,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } - /// Detect iOS apple-code 14/15 bond errors and clear the stale bond before retry + /// 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') || @@ -2749,6 +3729,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } if (!_autoPingEnabled) { + _cooldownTimer.stop(); + _pingService!.clearCooldown(); toggleAutoPing(previousMode); debugLog( '[CONN] Auto-ping restored after reconnect (mode=$previousMode)'); @@ -2808,6 +3790,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Mark as user-requested so BLE disconnect listener doesn't trigger auto-reconnect _userRequestedDisconnect = true; + // Immediate UI feedback + _connectionStep = ConnectionStep.disconnecting; + notifyListeners(); + // Cancel idle disconnect timer _cancelIdleDisconnectTimer(); @@ -2882,6 +3868,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _meshCoreConnection?.setAdvertName(_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( @@ -2919,7 +3908,6 @@ 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 @@ -2928,6 +3916,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 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(); @@ -2958,6 +3959,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _regionalChannels = []; _scope = null; + // Clear user-original preference tracking + _userOriginalAutoPingInterval = null; + _userOriginalHybridMode = null; + _userOriginalDiscDrop = null; + _userOriginalFloodTraffic = null; + // Clear zone transfer state _sessionZoneCode = null; _isZoneTransferInProgress = false; @@ -3010,24 +4017,33 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 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; } - // Reset idle disconnect timer (user is actively pinging) - _startIdleDisconnectTimer(); - - // 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'); try { + // Check session validity before starting (skip in offline mode) + if (!_preferences.offlineMode) { + final sessionCheck = await _checkSessionBeforeAction(); + if (!sessionCheck) return false; + } + + // Reset idle disconnect timer (user is actively pinging) + _startIdleDisconnectTimer(); + + debugLog('[PING] Sending manual TX ping'); return await _pingService!.sendTxPing(manual: true); } finally { - // Clear sending state when done (RX window timer will show listening state) + // Clear sending state on every path: session-check failure, exception, + // or success (RX window timer takes over showing the listening state) _isPingSending = false; notifyListeners(); } @@ -3140,115 +4156,131 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[PASSIVE MODE] Stopped - no cooldown (listen-only mode)'); } } else { - // Cancel idle disconnect timer — auto-ping keeps the session active - _cancelIdleDisconnectTimer(); + // 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; - // Check session validity before starting (skip in offline mode) - if (!_preferences.offlineMode) { - final sessionCheck = await _checkSessionBeforeAction(); - if (!sessionCheck) 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(); - // 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; - } + try { + // Cancel idle disconnect timer — auto-ping keeps the session active + _cancelIdleDisconnectTimer(); - // 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(); + // Check session validity before starting (skip in offline mode) + if (!_preferences.offlineMode) { + final sessionCheck = await _checkSessionBeforeAction(); + if (!sessionCheck) 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; } - // End existing noise floor session before starting new mode - await _endNoiseFloorSession(); - } - // Start new mode - debugLog('[PING] Starting auto mode: ${mode.name}'); - _autoMode = mode; + // 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(); + } - // 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)'); + // Start new mode + debugLog('[PING] Starting auto mode: ${mode.name}'); + _autoMode = mode; - final started = await _pingService!.enableAutoPing( - passiveMode: isPassive, - hybridMode: isHybrid, - targetedMode: isTargeted, - targetRepeaterId: isTargeted ? _targetRepeaterId : null, - ); - 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'); + // 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)'); + + final started = await _pingService!.enableAutoPing( + passiveMode: isPassive, + hybridMode: isHybrid, + targetedMode: isTargeted, + targetRepeaterId: isTargeted ? _targetRepeaterId : null, + ); + 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'); + } + return false; } - return false; - } - // Start RX wardriving for all modes - // Reference: state.rxTracking.isWardriving = true in wardrive.js - _rxLogger?.startWardriving(); - _autoPingEnabled = true; - _idleAutoStopReference = DateTime.now(); - - // 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); - }, + // Start RX wardriving for all modes + // Reference: state.rxTracking.isWardriving = true in wardrive.js + _rxLogger?.startWardriving(); + _autoPingEnabled = true; + _idleAutoStopReference = DateTime.now(); + + // 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'); + } + + // 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'); - } - - // 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, - ); + } finally { + // Clear starting state on every path (session/cooldown/blocked early + // returns, exceptions, and success) so the buttons never stay disabled + _autoPingStarting = false; + notifyListeners(); + } } notifyListeners(); @@ -3259,9 +4291,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void clearPings() { _txPings.clear(); _rxPings.clear(); + _discLogEntries.clear(); + _traceLogEntries.clear(); _clearOverlayState(); _pingService?.resetStats(); - notifyListeners(); + _notifyMapNow(); } /// Clear log entries @@ -3272,7 +4306,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _traceLogEntries.clear(); _errorLogEntries.clear(); _clearOverlayState(); - notifyListeners(); + _notifyMapNow(); } /// Add a discovery log entry (from Passive Mode) @@ -3283,7 +4317,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } debugLog( '[APP] Discovery log entry added: ${entry.nodeCount} nodes discovered'); - notifyListeners(); + _notifyMapNow(); } /// Add a trace log entry (from Trace Mode) @@ -3304,7 +4338,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { OverlayPingType.trace); } - notifyListeners(); + _notifyMapNow(); } /// Log a user-facing error message @@ -3387,6 +4421,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _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 @@ -3457,6 +4493,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _currentZone = null; _nearestZone = null; _lastZoneCheckPosition = null; + _regionBorders = []; + _bordersLoadedForZone = null; debugLog('[GEOFENCE] Cleared zone data for offline mode'); debugLog('[APP] Successfully switched to offline mode'); @@ -3476,6 +4514,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 { @@ -3512,7 +4551,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return (success: false, error: _modeSwitchError); } - // Re-check zone status BEFORE auth (zone data was cleared when entering offline mode) + // 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(); @@ -3541,6 +4583,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { power: _preferences.powerLevel, iataCode: zoneCode ?? _preferences.iataCode, model: modelString, + radioFreq: _meshCoreConnection?.selfInfo?.radioConfigApi, lat: _currentPosition!.latitude, lon: _currentPosition!.longitude, accuracyMeters: _currentPosition!.accuracy, @@ -3613,6 +4656,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { power: _preferences.powerLevel, iataCode: zoneCode ?? _preferences.iataCode, model: modelString, + radioFreq: _meshCoreConnection?.selfInfo?.radioConfigApi, lat: _currentPosition!.latitude, lon: _currentPosition!.longitude, accuracyMeters: _currentPosition!.accuracy, @@ -3647,8 +4691,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } // 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']; @@ -3670,6 +4713,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(); } @@ -3751,6 +4798,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 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; } @@ -3760,11 +4811,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ? _originalDeviceName : (_meshCoreConnection?.selfInfo?.name ?? connectedDeviceName?.replaceFirst('MeshCore-', '')); - await _offlineSessionService.saveSession( + // 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'); @@ -3792,6 +4853,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { devicePublicKey: _devicePublicKey, deviceName: offlineDeviceName, contactUri: _offlineContactUri, + radioConfig: _meshCoreConnection?.selfInfo?.radioConfigApi, + deviceModel: _meshCoreConnection?.deviceModel?.manufacturer ?? + _meshCoreConnection?.deviceInfo?.manufacturer ?? + _manufacturerString, + powerLevel: _preferences.powerLevel, + appVersion: _appVersion, ); } @@ -3901,17 +4968,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return OfflineUploadResult.invalidSession; } - // 2. Get device credentials from session - final publicKey = session.devicePublicKey; - if (publicKey == null) { - debugLog('[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; - final deviceName = session.deviceName; - if (deviceName == null || deviceName.isEmpty) { - debugLog('[OFFLINE] Session missing device name: $filename'); - return OfflineUploadResult.invalidSession; + 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; + } } onProgress?.call('Authenticating...'); @@ -3925,28 +4998,50 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 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'); - 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, - skipSessionStore: true, - ); + '[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'); - return OfflineUploadResult.authFailed; + debugError('[OFFLINE] Auth failed: network error after retries'); + return OfflineUploadResult.networkError; } if (authResult['success'] != true) { @@ -3961,10 +5056,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { reason: 'register', contactUri: session.contactUri, who: deviceName, - appVersion: _appVersion, - power: _preferences.powerLevel, + appVersion: uploadVersion, + power: uploadPower, iataCode: zoneCode ?? _preferences.iataCode, - model: 'Offline Upload', + model: uploadModel, + radioFreq: session.radioConfig, lat: _currentPosition?.latitude, lon: _currentPosition?.longitude, accuracyMeters: _currentPosition?.accuracy, @@ -3972,8 +5068,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { skipSessionStore: true, ); - if (registerResult == null || registerResult['success'] != true) { - final regReason = registerResult?['reason'] as String? ?? 'unknown'; + 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; } @@ -3997,29 +5097,90 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 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 using isolated session + // 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.uploadBatchWithSessionId(batch, offlineSessionId); + 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('[OFFLINE] Uploaded batch $batchNum: ${batch.length} pings'); - } else { - failedBatches++; - debugError('[OFFLINE] Failed to upload batch $batchNum'); + 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 @@ -4034,15 +5195,29 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); debugLog('[OFFLINE] Isolated upload session released'); - // 6. Mark session as uploaded (don't delete) if all batches succeeded - if (failedBatches == 0) { - await _offlineSessionService.markAsUploaded(filename); - debugLog('[OFFLINE] Uploaded ${pings.length} pings from $filename'); + // 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; + + 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 { + if (uploadedCount > 0) { + await _offlineSessionService.removeProcessedPings( + filename, uploadedCount); + debugLog( + '[OFFLINE] Removed $uploadedCount uploaded pings, $remainingPings preserved in $filename'); + } debugWarn( - '[OFFLINE] Partial upload: $uploadedCount/${pings.length} pings from $filename'); + '[OFFLINE] Partial upload: $uploadedCount uploaded, $remainingPings preserved in $filename'); notifyListeners(); return OfflineUploadResult.partialFailure; } @@ -4072,6 +5247,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _preferences = preferences; + // 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; @@ -4117,7 +5300,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { .setMinPingDistance(preferences.minPingDistanceMeters.toDouble()); PingService.currentMinDistance = preferences.minPingDistanceMeters; - notifyListeners(); + // Marker-style / GPS-marker prefs can change here — bump the map. + _notifyMapNow(); _savePreferences(); } @@ -4134,12 +5318,20 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _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(); + } } } } @@ -4184,10 +5376,31 @@ 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–1.0) and persist. + /// MapWidget watches `preferences.coverageOverlayOpacity` and applies the + /// new value to the raster layer at runtime via setLayerProperties, so the + /// overlay fades live as the slider moves. 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(); } @@ -4208,7 +5421,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { orElse: () => ColorVisionType.none), ); debugLog('[A11Y] Color vision type set to $type'); - notifyListeners(); + // 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(); } @@ -4284,6 +5503,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 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) { @@ -4298,7 +5527,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _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) @@ -4311,10 +5543,6 @@ 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 @@ -4342,6 +5570,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': @@ -4360,6 +5590,8 @@ 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.'; } @@ -4370,6 +5602,22 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { Future handleSessionError(String? reason, String? message) async { final userMessage = _getErrorMessage(reason, message); + // 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( @@ -4435,6 +5683,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { devicePublicKey: _devicePublicKey, deviceName: offlineDeviceName, contactUri: _offlineContactUri, + radioConfig: _meshCoreConnection?.selfInfo?.radioConfigApi, ); debugLog( '[APP] Preserved ${queuedPings.length} queued pings to offline storage on session expiry'); @@ -4720,14 +5969,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (reason == 'gps_inaccurate') { logError('GPS Accuracy Error\n$message', autoSwitch: false); - _zoneCheckError = message; - _zoneCheckErrorReason = 'gps_inaccurate'; - notifyListeners(); + // 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') { logError('GPS Stale Error\n$message', autoSwitch: false); - _zoneCheckError = message; - _zoneCheckErrorReason = 'gps_stale'; - notifyListeners(); + _scheduleZoneCheckRetry( + seconds: 10, error: message, reason: 'gps_stale'); } else if (reason == 'zone_disabled') { final errorMsg = _getErrorMessage(reason, message); logError(errorMsg); @@ -4770,13 +6020,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _currentZone = newZone; _nearestZone = null; + + final staleHours = result['stale_repeater_hours']; + if (staleHours is int && staleHours > 0) { + Repeater.staleHoursFallback = staleHours; + 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'; @@ -4902,6 +6162,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_autoPingEnabled) { _autoPingEnabled = false; _idleAutoStopReference = null; + await _pingService?.forceDisableAutoPing(); debugLog('[ZONE GRACE] Auto-ping paused'); } @@ -4983,9 +6244,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _cancelZoneGraceTimers(); _isInZoneGracePeriod = false; _zoneGraceSecondsRemaining = 0; + final savedAutoPing = _autoPingWasEnabledBeforeGrace; + final savedMode = _autoModeBeforeGrace; _autoPingWasEnabledBeforeGrace = false; await _handleZoneTransfer( - reEnteredZoneCode, _currentZone?['name'] ?? 'Unknown'); + reEnteredZoneCode, _currentZone?['name'] ?? 'Unknown', + wasAutoPingOverride: savedAutoPing, + previousModeOverride: savedMode); return; } @@ -5040,8 +6305,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return; } if (!_autoPingEnabled) { - toggleAutoPing(previousMode); - debugLog('[ZONE GRACE] Auto-ping restored (mode=$previousMode)'); + _cooldownTimer.stop(); + _pingService!.clearCooldown(); + final resolvedMode = _resolveAutoModeForZone(previousMode); + debugLog( + '[ZONE GRACE] Mode resolved: $previousMode → $resolvedMode'); + toggleAutoPing(resolvedMode); } }); } else { @@ -5076,6 +6345,29 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _abandonZoneGracePeriod(); } + /// 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; + + // 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; + } + + // Targeted/trace mode is transport-level, not channel TX — keep as-is + return desired; + } + // ============================================ // Zone-to-Zone Transfer // ============================================ @@ -5084,7 +6376,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Releases old zone session and acquires new session for target zone. /// Preserves BLE connection and radio configuration. Future _handleZoneTransfer( - String newZoneCode, String newZoneName) async { + String newZoneCode, String newZoneName, + {bool? wasAutoPingOverride, AutoMode? previousModeOverride}) async { if (_isZoneTransferInProgress) { debugLog('[ZONE] Transfer already in progress, skipping'); return; @@ -5099,8 +6392,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { // 1. Save auto-ping state for restoration - final wasAutoPing = _autoPingEnabled; - final previousMode = _autoMode; + // Prefer overrides from grace period (where provider state was already cleared) + final wasAutoPing = wasAutoPingOverride ?? _autoPingEnabled; + final previousMode = previousModeOverride ?? _autoMode; // 2. Pause auto-ping and wardriving activity _autoPingTimer.stop(); @@ -5109,6 +6403,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_autoPingEnabled) { _autoPingEnabled = false; _idleAutoStopReference = null; + await _pingService?.forceDisableAutoPing(); debugLog('[ZONE] Auto-ping paused for zone transfer'); } @@ -5156,16 +6451,18 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } debugLog('[ZONE] Requesting auth for zone $newZoneCode'); - final result = await _apiService.requestAuth( + 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: _meshCoreConnection?.deviceModel?.manufacturer ?? - _meshCoreConnection?.deviceInfo?.manufacturer ?? - 'Unknown', + model: modelString, + radioFreq: _meshCoreConnection?.selfInfo?.radioConfigApi, lat: _currentPosition!.latitude, lon: _currentPosition!.longitude, accuracyMeters: _currentPosition!.accuracy, @@ -5195,10 +6492,68 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final message = result['message'] as String? ?? 'Auth failed'; debugError( '[ZONE] Auth failed for zone $newZoneCode: $reason - $message'); - logError('Zone transfer failed: $message', - severity: ErrorSeverity.error); - await disconnect(); - return; + + // 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'); + + 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; + } + + 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 { + logError('Zone transfer failed: $message', + severity: ErrorSeverity.error); + await disconnect(); + return; + } } // 11. Auth succeeded — update session zone code @@ -5261,7 +6616,26 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[ZONE] No regional scope — using unscoped flood'); } - // 15. Enforce regional admin policies from new zone + // 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'); @@ -5270,6 +6644,18 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _preferences = _preferences.copyWith(discDropEnabled: true); debugLog('[ZONE] Discovery drop force-enabled by new zone admin'); } + final wasFloodEnabledByUser = _preferences.floodTrafficEnabled; + 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 (wasFloodEnabledByUser && _apiService.floodDisabled) { + _floodDisabledAlertPending = true; + } if (_preferences.autoPingInterval < _apiService.minModeInterval) { _preferences = _preferences.copyWith( autoPingInterval: _apiService.minModeInterval); @@ -5289,6 +6675,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _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: () { @@ -5318,8 +6709,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return; } if (!_autoPingEnabled) { - toggleAutoPing(previousMode); - debugLog('[ZONE] Auto-ping restored (mode=$previousMode)'); + _cooldownTimer.stop(); + _pingService!.clearCooldown(); + final resolvedMode = _resolveAutoModeForZone(previousMode); + debugLog( + '[ZONE] Mode resolved for new zone: $previousMode → $resolvedMode'); + toggleAutoPing(resolvedMode); } }); } else { @@ -5348,24 +6743,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await disconnect(); } - /// Force a repeater refetch when the cached list is empty (e.g. popup open, - /// offline session, startup race). Uses the current zone if known, otherwise - /// falls back to the user-configured IATA. No-op if neither is available or - /// if repeaters are already loaded. - Future refetchRepeatersIfPossible() async { - if (_repeaters.isNotEmpty) return; - final iata = (zoneCode?.isNotEmpty == true) - ? zoneCode - : _preferences.iataCode; - if (iata == null || iata.isEmpty) { - debugLog('[MAP] refetchRepeatersIfPossible: no IATA available, skipping'); - return; - } - _repeatersLoaded = false; - _repeatersLoadedForIata = null; - await _fetchRepeatersForZone(iata); - } - /// 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 { @@ -5383,7 +6760,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _repeatersLoaded = true; _repeatersLoadedForIata = iata; debugLog('[MAP] Loaded ${_repeaters.length} repeaters for zone $iata'); - notifyListeners(); + _notifyMapNow(); } else { debugWarn( '[MAP] No repeaters returned for zone $iata — will retry on next zone check'); @@ -5393,6 +6770,35 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + /// 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) // ============================================ @@ -5547,6 +6953,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. @@ -5731,7 +7159,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (json != null) { _rememberedDevice = RememberedDevice.fromJson(Map.from(json)); - debugLog('[APP] Loaded remembered device: ${_rememberedDevice!.name}'); + _selectedTransport = _rememberedDevice!.transportType; + debugLog('[APP] Loaded remembered device: ${_rememberedDevice!.name} (${_rememberedDevice!.transportType.name})'); notifyListeners(); } } catch (e) { @@ -5740,8 +7169,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } /// 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); @@ -5752,34 +7186,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 @@ -5849,6 +7300,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'); @@ -5883,6 +7335,7 @@ 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'); } @@ -5919,11 +7372,52 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { await box.put('device_power_overrides', _devicePowerOverrides); + await box.flush(); } catch (e) { debugLog('[APP] Failed to save device power overrides: $e'); } } + // ============================================ + // 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 // ============================================ @@ -5975,10 +7469,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'); @@ -5987,6 +7483,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 && @@ -6198,6 +7701,29 @@ 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 // ============================================ @@ -6208,6 +7734,57 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _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() { @@ -6235,7 +7812,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _offlineAutoSaveTimer?.cancel(); _zoneRefreshTimer?.cancel(); _cancelZoneGraceTimers(); - _tileRefreshTimer?.cancel(); + _vectorFreshTimer?.cancel(); + _mapThrottleTimer?.cancel(); _unifiedRxHandler?.dispose(); _meshCoreConnection?.dispose(); _pingService?.dispose(); @@ -6247,6 +7825,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _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 5e6b9a7..ee9ccb2 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -13,6 +13,9 @@ 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'; @@ -26,14 +29,23 @@ class ConnectionScreen extends StatefulWidget { 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(); } @@ -77,13 +89,17 @@ class _ConnectionScreenState extends State Widget build(BuildContext context) { final appState = context.watch(); - return Scaffold( - appBar: AppBar( - toolbarHeight: 40, - title: const Text('Connection', style: TextStyle(fontSize: 18)), - 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), ); } @@ -94,6 +110,7 @@ class _ConnectionScreenState extends State } if (appState.connectionStep != ConnectionStep.disconnected && appState.connectionStep != ConnectionStep.connected && + appState.connectionStep != ConnectionStep.disconnecting && appState.connectionStep != ConnectionStep.error) { return _buildConnectionProgress(context, appState); } @@ -120,6 +137,20 @@ class _ConnectionScreenState extends State 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) { final pathWarning = appState.pendingPathHashWarning; @@ -143,26 +174,43 @@ class _ConnectionScreenState extends State return _buildDeviceList(context, appState); } - /// Persistent bottom action bar: offline toggle + scan/cancel/disconnect + /// Persistent bottom action bar: transport picker + offline toggle + action button Widget _buildBottomBar(BuildContext context, AppStateProvider appState) { return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Row( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - const Expanded(child: OfflineModeToggle()), - const SizedBox(width: 12), - Expanded(child: _buildActionButton(context, appState)), + 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, Cancel, or Disconnect + /// 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) { - // Disconnect return _buildBottomButton( - icon: Icons.bluetooth_disabled, + icon: Icons.link_off, label: 'Disconnect', color: Colors.red, onPressed: () async => await appState.disconnect(), @@ -170,7 +218,6 @@ class _ConnectionScreenState extends State } if (appState.isScanning) { - // Cancel scan return _buildBottomButton( icon: Icons.close, label: 'Cancel', @@ -179,19 +226,54 @@ class _ConnectionScreenState extends State ); } - // Scan — disabled when can't scan - final canScan = appState.connectionStep == ConnectionStep.disconnected && - !appState.isAutoReconnecting && - (!appState.maintenanceMode || appState.offlineMode) && - !appState.isBluetoothOff && - (appState.offlineMode || appState.inZone == true); - - return _buildBottomButton( - icon: Icons.bluetooth_searching, - label: 'Scan', - color: Theme.of(context).colorScheme.primary, - onPressed: canScan ? () => appState.startScan() : null, - ); + 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 @@ -578,6 +660,12 @@ class _ConnectionScreenState extends State 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), @@ -723,6 +811,46 @@ class _ConnectionScreenState extends State ); } + /// 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); @@ -1569,8 +1697,9 @@ class _ConnectionScreenState extends State ); } - // Show Bluetooth off message (takes priority over zone checks) - if (appState.isBluetoothOff) { + // Show Bluetooth off message (BLE transport only) + if (appState.selectedTransport == TransportType.ble && + appState.isBluetoothOff) { return _buildMessageContent( context: context, icon: Icons.bluetooth_disabled, @@ -1743,6 +1872,106 @@ class _ConnectionScreenState extends State ); } + 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: [ + 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: [ @@ -1755,18 +1984,16 @@ class _ConnectionScreenState extends State } if (appState.discoveredDevices.isEmpty) { - // Show remembered device option if available (mobile only) final remembered = appState.rememberedDevice; - if (!kIsWeb && remembered != null) { + 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 _buildMessageContent( context: context, icon: Icons.gps_off, @@ -1784,7 +2011,6 @@ class _ConnectionScreenState extends State ); } - // Show GPS permission required message when permissions are denied if (appState.gpsStatus == GpsStatus.permissionDenied) { return _buildMessageContent( context: context, @@ -1813,6 +2039,259 @@ class _ConnectionScreenState extends State 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: [ + // 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); + } + + 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 { @@ -1997,7 +2476,9 @@ class _ConnectionScreenState extends State ), 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), ), ], diff --git a/lib/screens/graph_screen.dart b/lib/screens/graph_screen.dart index 623520d..6fe8e08 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}); @@ -20,8 +20,7 @@ class GraphScreen extends StatelessWidget { return Scaffold( appBar: AppBar( toolbarHeight: 40, - title: - const Text('Noise Floor History', style: TextStyle(fontSize: 18)), + title: const Text('Session History', style: TextStyle(fontSize: 18)), automaticallyImplyLeading: false, actions: [ if (sessions.isNotEmpty) @@ -32,16 +31,16 @@ class GraphScreen extends StatelessWidget { ), ], ), - 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( @@ -50,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, ), @@ -61,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, @@ -75,27 +74,33 @@ class GraphScreen extends StatelessWidget { return ListView( children: [ - // Current active session (if recording) if (currentSession != null) ...[ - _SessionListTile( + _SessionCard( session: currentSession, isActive: true, - onTap: () => + 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, )), ], ); } + 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( @@ -112,7 +117,7 @@ class GraphScreen extends StatelessWidget { 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.'), + 'This will delete all saved session history. The current active session will not be affected.'), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -131,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; @@ -161,7 +322,6 @@ class _FullScreenGraphPageState extends State<_FullScreenGraphPage> { _session = current; }); } else { - // Session ended while viewing _liveTimer?.cancel(); _liveTimer = null; setState(() { @@ -214,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', @@ -227,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( @@ -254,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( @@ -266,7 +423,6 @@ class _FullScreenGraphPageState extends State<_FullScreenGraphPage> { ), ), ), - // Chart Expanded( child: Padding( padding: const EdgeInsets.fromLTRB(8, 0, 16, 8), @@ -287,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 90c9403..e42d7cc 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -29,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(() { @@ -115,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'), @@ -422,17 +471,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, @@ -475,8 +531,12 @@ class _HomeScreenState extends State { ), ), - // Portrait: bottom control panel - if (!isLandscape) + // 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, @@ -486,18 +546,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), ), @@ -638,6 +723,19 @@ class _HomeScreenState extends State { ), ), ), + // 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), + ), + ), + ), // Close button - larger touch target Material( color: Colors.transparent, @@ -668,6 +766,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; diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index e7bd7f7..951eba4 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -7,6 +7,7 @@ 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 two tabs: All Pings (unified TX+RX+DISC+TRC) and Errors class LogScreen extends StatefulWidget { @@ -175,7 +176,7 @@ class _LogScreenState extends State if (rx.isNotEmpty) { buffer.writeln('--- RX Log ---'); buffer.writeln( - 'timestamp,repeater_id,snr,rssi,path_length,header,latitude,longitude'); + 'timestamp,repeater_id,snr,rssi,path_length,header,latitude,longitude,path_hops'); for (final entry in rx) { buffer.writeln(entry.toCsv()); } @@ -339,10 +340,6 @@ class _AllPingsTabState extends State<_AllPingsTab> { } /// True if the query looks like a hex string (only 0-9, a-f). - static bool _isHexQuery(String query) { - return RegExp(r'^[0-9a-fA-F]+$').hasMatch(query); - } - /// Whether an entry matches the current search query. bool _matchesSearch(UnifiedPingLogEntry entry, List repeaters) { if (_searchQuery.isEmpty) return true; @@ -358,6 +355,13 @@ class _AllPingsTabState extends State<_AllPingsTab> { 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; @@ -367,9 +371,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { case PingLogType.disc: final disc = entry.asDisc; for (final node in disc.discoveredNodes) { - if (node.repeaterId.toLowerCase().startsWith(query)) { - return true; - } + if (node.repeaterId.toLowerCase().startsWith(query)) return true; if (node.pubkeyHex != null && node.pubkeyHex!.toLowerCase().startsWith(query)) { return true; @@ -393,12 +395,12 @@ class _AllPingsTabState extends State<_AllPingsTab> { /// Only shown when searching by name (non-hex query) and a repeater ID is ambiguous. bool _shouldShowAmbiguity( UnifiedPingLogEntry entry, List repeaters) { - if (_searchQuery.isEmpty || _isHexQuery(_searchQuery)) return false; - switch (entry.type) { case PingLogType.tx: return entry.asTx.events - .any((e) => _isAmbiguousId(e.repeaterId, repeaters)); + .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: @@ -678,6 +680,15 @@ class _AllPingsTabState extends State<_AllPingsTab> { // 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; + return 80; + } + Widget _buildTxCard(BuildContext context, TxLogEntry entry, {bool showAmbiguity = false}) { final appState = context.read(); @@ -695,11 +706,29 @@ class _AllPingsTabState extends State<_AllPingsTab> { _buildCardHeader(context, PingLogType.tx, entry.timeString, entry.locationString, showAmbiguity: showAmbiguity), - // Repeaters table + // Direct echoes table if (entry.events.isNotEmpty) ...[ const SizedBox(height: 10), - _buildRepeaterTable(context, entry.events), - ] else ...[ + _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, + ), + ), + ], + // 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', @@ -717,7 +746,14 @@ class _AllPingsTabState extends State<_AllPingsTab> { ); } - Widget _buildRepeaterTable(BuildContext context, List events) { + 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, @@ -732,22 +768,25 @@ class _AllPingsTabState extends State<_AllPingsTab> { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Row( children: [ - SizedBox(width: 60, child: _tableHeader(context, 'Node')), + 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) => _buildTxRepeaterRow(context, event)), + ...events.map((event) => + _buildTxRepeaterRow(context, event, nodeWidth, repeaters)), ], ), ); } - Widget _buildTxRepeaterRow(BuildContext context, RxEvent event) { + 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( @@ -755,7 +794,10 @@ class _AllPingsTabState extends State<_AllPingsTab> { child: Row( children: [ RepeaterIdChip( - repeaterId: event.repeaterId, fontSize: 14, width: 60), + repeaterId: event.repeaterId, + fontSize: 14, + width: nodeWidth, + isAmbiguous: isAmbiguous), Expanded( child: Center( child: _buildChip( @@ -771,6 +813,130 @@ class _AllPingsTabState extends State<_AllPingsTab> { ); } + // --------------------------------------------------------------------------- + // 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.route, size: 14, color: PingColors.rx), + const SizedBox(width: 6), + Text( + 'Multi-hop Repeats', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + 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: [ + 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))), + ], + ), + ), + ), + 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: RxPathChain( + hops: event.pathHops, + fontSize: 11, + ), + ), + ], + ), + ), + ], + ); + }), + ], + ), + ); + } + // --------------------------------------------------------------------------- // RX Card // --------------------------------------------------------------------------- @@ -780,6 +946,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { 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), @@ -815,7 +983,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { child: Row( children: [ SizedBox( - width: 60, child: _tableHeader(context, 'Node')), + width: nodeWidth, + child: _tableHeader(context, 'Node')), Expanded( child: _tableHeader(context, 'SNR', center: true)), @@ -837,7 +1006,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { RepeaterIdChip( repeaterId: entry.repeaterId, fontSize: 14, - width: 60), + width: nodeWidth, + isAmbiguous: isAmbiguous), Expanded( child: Center( child: _buildChip( @@ -857,6 +1027,31 @@ class _AllPingsTabState extends State<_AllPingsTab> { ], ), ), + 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, + ), + ), + ], + ), + ], ], ), ), @@ -923,8 +1118,8 @@ class _AllPingsTabState extends State<_AllPingsTab> { ), ), Divider(height: 1, color: Theme.of(context).dividerColor), - ...entry.discoveredNodes - .map((node) => _buildDiscNodeRow(context, node)), + ...entry.discoveredNodes.map((node) => + _buildDiscNodeRow(context, node, widget.repeaters)), ], ), ), @@ -946,10 +1141,12 @@ class _AllPingsTabState extends State<_AllPingsTab> { ); } - Widget _buildDiscNodeRow(BuildContext context, DiscoveredNodeEntry node) { + 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, @@ -964,7 +1161,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { children: [ Flexible( child: RepeaterIdChip( - repeaterId: node.repeaterId, fontSize: 14)), + repeaterId: node.repeaterId, + fontSize: 14, + isAmbiguous: isAmbiguous)), Text( node.nodeTypeLabel, style: TextStyle( @@ -1049,7 +1248,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { ), ), Divider(height: 1, color: Theme.of(context).dividerColor), - _buildTraceNodeRow(context, entry), + _buildTraceNodeRow(context, entry, widget.repeaters), ], ), ), @@ -1071,10 +1270,12 @@ class _AllPingsTabState extends State<_AllPingsTab> { ); } - Widget _buildTraceNodeRow(BuildContext context, TraceLogEntry entry) { + 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), @@ -1083,7 +1284,9 @@ class _AllPingsTabState extends State<_AllPingsTab> { SizedBox( width: 70, child: RepeaterIdChip( - repeaterId: entry.targetRepeaterId, fontSize: 14)), + repeaterId: entry.targetRepeaterId, + fontSize: 14, + isAmbiguous: isAmbiguous)), Expanded( child: Center( child: _buildChip( diff --git a/lib/screens/main_scaffold.dart b/lib/screens/main_scaffold.dart index a3cdfef..27815d7 100644 --- a/lib/screens/main_scaffold.dart +++ b/lib/screens/main_scaffold.dart @@ -6,6 +6,7 @@ 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 '../utils/debug_logger_io.dart'; @@ -27,6 +28,7 @@ class _MainScaffoldState extends State { int _selectedIndex = 0; bool _hasCheckedDisclosure = false; bool _hasShownLocationSettingsPrompt = false; + bool _floodDisabledDialogOpen = false; final List _screens = [ const HomeScreen(), @@ -133,6 +135,31 @@ class _MainScaffoldState extends State { } } + Future _showFloodDisabledDialog() async { + final appState = context.read(); + debugLog('[APP] Showing flood-traffic-disabled-by-region alert'); + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Flood Traffic Unavailable'), + content: const Text( + 'Your regional MeshMapper admin has disabled flood traffic in this ' + 'area, so Active and Hybrid modes have been turned off for this ' + 'session. Passive Mode and Trace Mode remain available. Please ' + 'reach out to your regional admin if you have questions.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('OK'), + ), + ], + ), + ); + appState.clearFloodDisabledAlert(); + _floodDisabledDialogOpen = false; + } + void _showLocationSettingsPrompt() { if (!mounted || _hasShownLocationSettingsPrompt) return; _hasShownLocationSettingsPrompt = true; @@ -176,15 +203,13 @@ class _MainScaffoldState extends State { }); } - // Listen for connection tab requests - switch to Connect tab (e.g. anonymous mode reconnect) - if (appState.requestConnectionTabSwitch && _selectedIndex != 3) { + // Listen for flood-traffic-disabled-by-region alert (user had it on, + // region forced it off on auth/zone-change) + if (appState.floodDisabledAlertPending && !_floodDisabledDialogOpen) { + _floodDisabledDialogOpen = true; WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() { - _selectedIndex = 3; // Switch to Connect tab - }); - appState.clearConnectionTabSwitchRequest(); - } + if (!mounted) return; + _showFloodDisabledDialog(); }); } @@ -192,9 +217,22 @@ class _MainScaffoldState extends State { 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) @@ -202,6 +240,80 @@ 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) { return Container( @@ -230,8 +342,8 @@ 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( @@ -321,9 +433,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 0000000..5549f91 --- /dev/null +++ b/lib/screens/offline_maps_screen.dart @@ -0,0 +1,1665 @@ +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(); + String _selectedStyle = 'Liberty'; + double _minZoom = 6; + double _maxZoom = 14; + bool _submitting = false; + String? _error; + + // 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(); + 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); + } + + bool get _canSubmit => + _nameController.text.trim().isNotEmpty && + _selectedBounds != null && + !_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, + 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), + ), + onChanged: (_) => setState(() {}), + ), + 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 { + final bounds = _selectedBounds; + if (bounds == null) return; + + final name = _nameController.text.trim(); + if (name.isEmpty) 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 5f269d7..d458128 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -30,6 +30,7 @@ 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'; /// Settings screen for user preferences and API configuration class SettingsScreen extends StatefulWidget { @@ -154,26 +155,6 @@ class _SettingsScreenState extends State { }, ), if (!kIsWeb) _BackgroundModeToggle(appState: appState), - SwitchListTile( - secondary: - Icon(prefs.mapTilesEnabled ? Icons.map : Icons.map_outlined), - title: const Text('Disable Map Tiles'), - subtitle: Text(prefs.mapTilesEnabled - ? 'Map and coverage tiles load normally' - : 'Disabled to save mobile data'), - value: !prefs.mapTilesEnabled, - onChanged: (value) { - appState - .updatePreferences(prefs.copyWith(mapTilesEnabled: !value)); - }, - ), - 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), - ), SwitchListTile( secondary: Icon( prefs.isImperial ? Icons.square_foot : Icons.straighten, @@ -186,31 +167,6 @@ class _SettingsScreenState extends State { appState.setUnitSystem(isImperial ? 'imperial' : 'metric'); }, ), - 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)); - }, - ), - 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: Icon( appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), @@ -247,6 +203,96 @@ class _SettingsScreenState extends State { ], ]), + // 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), + ), + 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)); + }, + ), + ]), + // Ping Settings _buildSection(context, 'Ping Settings', [ SwitchListTile( @@ -271,6 +317,15 @@ class _SettingsScreenState extends State { } }, ), + 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 Icon(Icons.timer), title: const Text('Auto-Ping Interval'), @@ -308,6 +363,24 @@ class _SettingsScreenState extends State { // 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 disabled in this region. Active and Hybrid modes are unavailable here.', + style: TextStyle(color: Colors.amber), + ) + : 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( @@ -745,12 +818,15 @@ class _SettingsScreenState extends State { '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'), - ), + // 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) @@ -1232,6 +1308,58 @@ class _SettingsScreenState extends State { }; } + /// 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: [ + 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, + ), + ], + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + void _showColorVisionSelector( BuildContext context, AppStateProvider appState) { final options = [ @@ -1400,7 +1528,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( @@ -2134,7 +2262,7 @@ class _SettingsScreenState extends State { switch (result) { case OfflineUploadResult.success: - message = 'Uploaded: $filename'; + message = 'Upload Success'; backgroundColor = Colors.green; break; case OfflineUploadResult.notFound: @@ -2149,18 +2277,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( @@ -2200,8 +2336,8 @@ 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); @@ -2237,13 +2373,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) { @@ -2744,11 +2876,83 @@ class _OfflineSessionTile extends StatelessWidget { 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, @@ -2759,9 +2963,9 @@ class _OfflineSessionTile extends StatelessWidget { children: [ Text('${session.pingCount} pings • ${session.displayDate}'), if (isUploaded) - const Text( - 'Uploaded', - style: TextStyle( + Text( + _hasSummary ? 'Uploaded · ${_placementSummary()}' : 'Uploaded', + style: const TextStyle( color: Colors.green, fontSize: 12, fontWeight: FontWeight.w500), diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index 4ed600a..ced5ea8 100644 --- a/lib/services/api_queue_service.dart +++ b/lib/services/api_queue_service.dart @@ -43,8 +43,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; @@ -237,6 +240,8 @@ class ApiQueueService { required bool externalAntenna, int? noiseFloor, double? power, + int? pingCounter, + String? wireTag, }) async { final item = ApiQueueItem.fromTx( latitude: latitude, @@ -246,6 +251,8 @@ class ApiQueueService { externalAntenna: externalAntenna, noiseFloor: noiseFloor, power: power, + pingCounter: pingCounter, + wireTag: wireTag, ); // In offline mode, accumulate to offline pings list instead of queue @@ -572,11 +579,15 @@ 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]; + 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}'); + '[API QUEUE] Item ${i + 1}/${items.length}: type=${item.type}, external_antenna=${item.externalAntenna}$tagInfo'); } final memoryCount = memoryItems.length; @@ -603,7 +614,7 @@ class ApiQueueService { _memoryQueue.remove(item); } debugLog('[API QUEUE] Upload SUCCESS: deleted $uploadedCount items'); - onUploadSuccess?.call(uploadedCount); + onUploadSuccess?.call(uploadedCount, items); // Fire-and-forget: forward to custom API endpoint customApiService?.forwardPings(pings); } else if (result == UploadResult.nonRetryable) { diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 1cdd432..ec98dc1 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:math'; +import 'dart:typed_data'; import 'package:http/http.dart' as http; @@ -8,7 +9,7 @@ import '../models/repeater.dart'; import '../utils/debug_logger_io.dart'; /// Result of a batch upload attempt -enum UploadResult { success, retryable, nonRetryable } +enum UploadResult { success, retryable, sessionError, nonRetryable } /// MeshMapper API service /// Handles communication with the MeshMapper backend @@ -25,6 +26,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'); @@ -38,6 +40,8 @@ class ApiService { 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; @@ -48,6 +52,7 @@ class ApiService { List _scopes = []; bool _enforceHybrid = false; bool _enforceDiscDrop = false; + bool _floodDisabled = false; int _minModeInterval = 15; int _apiHopBytes = 1; @@ -67,6 +72,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; @@ -148,6 +156,18 @@ class ApiService { /// 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; @@ -221,6 +241,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 /// @@ -240,6 +333,7 @@ class ApiService { double? power, String? iataCode, String? model, + String? radioFreq, double? lat, double? lon, double? accuracyMeters, @@ -281,6 +375,7 @@ class ApiService { } 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 @@ -338,6 +433,23 @@ class ApiService { _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 channels array from auth response final channelsData = data['channels']; if (channelsData is List) { @@ -368,6 +480,12 @@ class ApiService { debugLog('[API] Regional admin enforces discovery drop'); } + // 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) { @@ -751,6 +869,8 @@ class ApiService { /// Clear session data and cancel all timers void _clearSession() { _sessionId = null; + _wireKey = null; + _pingCounter = 0; _txAllowed = false; _rxAllowed = false; _sessionExpiresAt = null; @@ -758,6 +878,7 @@ class ApiService { _scopes = []; _enforceHybrid = false; _enforceDiscDrop = false; + _floodDisabled = false; _minModeInterval = 15; _apiHopBytes = 1; _heartbeatTimer?.cancel(); @@ -778,6 +899,51 @@ class ApiService { /// Callback for maintenance mode detection (while connected) void Function(String message, String? url)? onMaintenanceMode; + /// 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 @@ -915,6 +1081,96 @@ 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. + Future>> fetchMapData({ + required String zone, + required double lat, + required double lon, + required double radiusMeters, + }) { + return _fetchCoveragePoints( + zone: zone, + label: 'map_data', + body: { + 'request': 'map_data', + 'lat': lat, + 'lon': lon, + 'radius': radiusMeters, + }, + ); + } + + /// 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 `[]` + /// on any failure. + 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 a list of point maps, or `[]` on any failure. + 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 []; + } + + final decoded = json.decode(response.body); + if (decoded is! List) { + debugWarn('[COVERAGE] $label: unexpected response (not a JSON list)'); + return []; + } + 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 []; + } + } + /// Submit wardrive data using an explicit session ID (for offline uploads) /// Does NOT read/write shared _sessionId, _sessionExpiresAt, or heartbeat state Future?> submitWardriveDataWithSessionId( @@ -981,8 +1237,9 @@ class ApiService { /// Returns UploadResult only — does NOT call _clearSession(), onSessionError, or onMaintenanceMode Future uploadBatchWithSessionId( List> pings, - String sessionId, - ) async { + String sessionId, { + void Function(Map response)? onResponse, + }) async { if (pings.isEmpty) return UploadResult.success; try { @@ -994,18 +1251,30 @@ class ApiService { } 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?; - // For offline uploads, session/auth errors are non-retryable but do NOT cascade - const criticalErrors = { + // 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', - 'bad_session', + }; + 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', @@ -1013,8 +1282,8 @@ class ApiService { 'zone_full', 'zone_disabled', }; - if (criticalErrors.contains(reason)) { - debugError('[API] Offline upload batch session error: $reason'); + if (permanentSessionErrors.contains(reason)) { + debugError('[API] Offline upload batch permanent error: $reason'); return UploadResult.nonRetryable; } @@ -1027,7 +1296,7 @@ class ApiService { }; if (nonRetryableErrors.contains(reason)) { debugWarn( - '[API] Offline upload batch non-retryable error: $reason - discarding batch'); + '[API] Offline upload batch non-retryable error: $reason - stopping upload, pings preserved'); return UploadResult.nonRetryable; } diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 27cc57b..d0f0c90 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -68,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, @@ -130,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'); @@ -266,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, @@ -345,6 +349,7 @@ class AudioService { if (box == null) return; try { await box.put(key, value); + await box.flush(); } catch (e) { debugError('[AUDIO] Failed to save $key: $e'); } diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart index 2d7bed5..d5dd44e 100644 --- a/lib/services/background_service.dart +++ b/lib/services/background_service.dart @@ -82,6 +82,7 @@ class BackgroundServiceManager { foregroundServiceTypes: [ AndroidForegroundType.location, AndroidForegroundType.connectedDevice, + AndroidForegroundType.dataSync, ], ), iosConfiguration: IosConfiguration( diff --git a/lib/services/bluetooth/bluetooth_service.dart b/lib/services/bluetooth/bluetooth_service.dart index 4702178..47bc82f 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 @@ -77,9 +82,11 @@ abstract class BluetoothService { Future connect(String deviceId); /// Disconnect from current device + @override Future disconnect(); /// Write data to device + @override Future write(Uint8List data); /// Pre-populate device cache with known device info @@ -93,5 +100,6 @@ abstract class BluetoothService { 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 47a5f23..7051389 100644 --- a/lib/services/bluetooth/mobile_bluetooth.dart +++ b/lib/services/bluetooth/mobile_bluetooth.dart @@ -439,9 +439,23 @@ class MobileBluetoothService implements BluetoothService { if ((isError133 || isBondError) && attempt < _maxRetries) { if (isBondError) { debugLog( - '[BLE] Bond error (apple-code 14/15) on attempt $attempt, removing bond and retrying...'); + '[BLE] Bond error (apple-code 14/15) on attempt $attempt'); await removeBond(deviceId); - await Future.delayed(const Duration(seconds: 2)); + // 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)'); + try { + await _bleDevice?.disconnect(); + } catch (_) {} + _bleDevice = null; + _rxCharacteristic = null; + _txCharacteristic = null; + _updateStatus(ConnectionStatus.error); + rethrow; } else { debugLog( '[BLE] Error 133 on attempt $attempt, retrying after delay...'); diff --git a/lib/services/countdown_timer_service.dart b/lib/services/countdown_timer_service.dart index 412bd3f..c7e61de 100644 --- a/lib/services/countdown_timer_service.dart +++ b/lib/services/countdown_timer_service.dart @@ -1,22 +1,20 @@ 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}); + int? _durationMs; /// Check if timer is running bool get isRunning => _timer != null; @@ -41,8 +39,8 @@ 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 @@ -50,7 +48,7 @@ class CountdownTimerService { Timer.periodic(const Duration(milliseconds: 500), (_) => _update()); // Trigger immediate update - _update(); + notifyListeners(); } /// Update countdown display @@ -61,35 +59,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; @@ -106,8 +112,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) { @@ -118,19 +122,13 @@ 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; diff --git a/lib/services/gps_service.dart b/lib/services/gps_service.dart index 43f951a..4167116 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 @@ -246,6 +247,14 @@ class GpsService { 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); @@ -390,6 +399,11 @@ class GpsService { 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) { @@ -411,10 +425,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; @@ -456,6 +476,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); @@ -467,9 +492,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/meshcore/channel_service.dart b/lib/services/meshcore/channel_service.dart index d92573c..56f04c0 100644 --- a/lib/services/meshcore/channel_service.dart +++ b/lib/services/meshcore/channel_service.dart @@ -177,7 +177,6 @@ class ChannelService { // 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) @@ -222,6 +221,13 @@ class ChannelService { // #wardriving not found - create it at first empty slot if (firstEmptySlot == null) { + 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( diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 1ed7977..9a59331 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'; @@ -40,12 +40,25 @@ 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 @@ -53,6 +66,37 @@ class SelfInfo { .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; + } } /// MeshCore connection manager @@ -69,7 +113,7 @@ 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 = @@ -97,6 +141,7 @@ class MeshCoreConnection { Completer? _channelInfoCompleter; Completer? _statsCompleter; Completer? _exportContactCompleter; + Completer? _getTimeCompleter; // Device self info (contains public key) SelfInfo? _selfInfo; @@ -116,9 +161,9 @@ 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 @@ -205,16 +250,15 @@ class MeshCoreConnection { /// 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 { + 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); @@ -281,6 +325,13 @@ class MeshCoreConnection { '[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'); @@ -315,7 +366,7 @@ class MeshCoreConnection { _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'); @@ -349,7 +400,7 @@ class MeshCoreConnection { // See deleteWardrivingChannelEarly() called from app_state_provider // Disconnect BLE - await _bluetooth.disconnect(); + await _transport.disconnect(); _deviceInfo = null; _deviceModel = null; _selfInfo = null; @@ -434,6 +485,8 @@ class MeshCoreConnection { _deviceQueryCompleter = null; _exportContactCompleter?.completeError(errException); _exportContactCompleter = null; + _getTimeCompleter?.completeError(errException); + _getTimeCompleter = null; break; case ResponseCodes.deviceInfo: _onDeviceInfoResponse(reader); @@ -468,6 +521,12 @@ class MeshCoreConnection { 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; @@ -571,17 +630,23 @@ 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 + 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 @@ -593,11 +658,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)}...'); + '[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; @@ -775,7 +848,7 @@ class MeshCoreConnection { /// Write frame to device Future _sendToRadio(BufferWriter data) async { - await _bluetooth.write(data.toBytes()); + await _transport.write(data.toBytes()); } // ============================================ @@ -824,6 +897,10 @@ 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'), @@ -849,6 +926,24 @@ class MeshCoreConnection { ); } + /// 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 Future setTxPower(int txPower) async { final data = BufferWriter(); @@ -891,7 +986,7 @@ class MeshCoreConnection { final bytes = data.toBytes(); debugLog( '[CONN] getChannel bytes: ${bytes.map((b) => '0x${b.toRadixString(16).padLeft(2, '0')}').join(' ')}'); - await _bluetooth.write(bytes); + await _transport.write(bytes); return future.timeout( const Duration(seconds: 5), @@ -1010,20 +1105,18 @@ class MeshCoreConnection { ); } - /// Send ping to #wardriving channel - /// Format: @[MapperBot] LAT, LON - /// Power is no longer included in the mesh message — it is sent per-ping in the API payload instead - /// Reference: buildPayload() in wardrive.js - Future sendPing(double lat, double lon) 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)}'; - final message = '@[MapperBot] $coordsStr'; - debugLog('[CONN] Sending ping: $message'); final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; await sendChannelTextMessage( diff --git a/lib/services/meshcore/protocol_constants.dart b/lib/services/meshcore/protocol_constants.dart index 1e2de5e..ba4bb0f 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; // ">" diff --git a/lib/services/meshcore/rx_logger.dart b/lib/services/meshcore/rx_logger.dart index c78829a..9307445 100644 --- a/lib/services/meshcore/rx_logger.dart +++ b/lib/services/meshcore/rx_logger.dart @@ -150,6 +150,15 @@ class RxLogger { 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, @@ -161,6 +170,7 @@ class RxLogger { lon: gpsLocation.lon, timestamp: DateTime.now(), metadata: metadata, + displayHops: displayHops, ); // Handle tracking for API (best SNR with distance trigger) @@ -173,6 +183,7 @@ class RxLogger { header: metadata.header, currentLocation: gpsLocation, metadata: metadata, + displayHops: displayHops, ); // Only fire immediate callback if this observation was actually kept @@ -211,6 +222,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]; @@ -230,6 +242,7 @@ class RxLogger { lon: currentLocation.lon, timestamp: DateTime.now(), metadata: metadata, + displayHops: displayHops, ), ); _batchBuffer[repeaterId] = buffer; @@ -266,6 +279,7 @@ class RxLogger { lon: buffer.firstLocation.lon, // Keep original location timestamp: DateTime.now(), metadata: metadata, + displayHops: displayHops, ); wasKept = true; // Better SNR, observation is kept } else { @@ -371,6 +385,7 @@ class RxLogger { header: best.header, timestamp: best.timestamp, metadata: best.metadata, + displayHops: best.displayHops, ); debugLog('[RX BATCH] Posting repeater $repeaterId: snr=${best.snr}, ' @@ -477,6 +492,9 @@ class RxObservation { final double lon; 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, @@ -488,6 +506,7 @@ class RxObservation { required this.lon, required this.timestamp, required this.metadata, + this.displayHops = const [], }); } @@ -502,6 +521,8 @@ class RxApiEntry { final int header; final DateTime timestamp; final PacketMetadata metadata; + /// Display path hops, origin → ... → us. Already CARpeater-stripped. + final List displayHops; RxApiEntry({ required this.repeaterId, @@ -513,5 +534,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 index 265c6e4..6d2ddcd 100644 --- a/lib/services/meshcore/trace_tracker.dart +++ b/lib/services/meshcore/trace_tracker.dart @@ -214,8 +214,12 @@ class TraceTracker { onWindowComplete?.call(result); } - /// Dispose of resources + /// Dispose of resources — fires onWindowComplete if a window was active void dispose() { - stopTracking(); + if (isListening) { + _endWindow(); + } else { + stopTracking(); + } } } diff --git a/lib/services/meshcore/tx_tracker.dart b/lib/services/meshcore/tx_tracker.dart index b4090e2..92226f2 100644 --- a/lib/services/meshcore/tx_tracker.dart +++ b/lib/services/meshcore/tx_tracker.dart @@ -17,21 +17,29 @@ 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; + /// 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 void Function(String repeaterId, String reason)? onCarpeaterDrop; @@ -69,6 +77,7 @@ class TxTracker { expectedChannelHash = channelHash; this.channelKey = channelKey; repeaters.clear(); + multiHopRepeaters.clear(); // Start window timer _windowTimer?.cancel(); @@ -81,7 +90,8 @@ class TxTracker { /// Stop tracking echoes void stopTracking() { debugLog( - '[TX LOG] Stopping echo tracking (heard ${repeaters.length} repeaters)'); + '[TX LOG] Stopping echo tracking (heard ${repeaters.length} direct, ' + '${multiHopRepeaters.length} multi-hop repeaters)'); isListening = false; _windowTimer?.cancel(); @@ -91,15 +101,22 @@ class TxTracker { 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'); + '[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; @@ -112,17 +129,16 @@ class TxTracker { 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')}'); // 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; + return TxEchoResult.notEcho; } // Extract first hop (first repeater) for use in validation and logging @@ -137,7 +153,7 @@ class TxTracker { 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)!; @@ -149,28 +165,31 @@ class TxTracker { reportedRssi = null; } - // Only direct (1-hop) echoes yield a meaningful SNR/RSSI for the repeater - // that heard our TX: the radio reports last-hop link quality, so for any - // multi-hop relay the metrics describe a different link entirely. - if (!carpeaterStripped && metadata.pathHashCount > 1) { + // 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] Ignoring: multi-hop echo (pathHashCount=${metadata.pathHashCount}) ' - 'from $pathHex — SNR/RSSI would measure last-hop link, not repeater downlink'); - return false; + '[TX LOG] Multi-hop echo (pathHashCount=${metadata.pathHashCount}): ' + 'reporting repeater=$pathHex, path=${displayHops.join(' → ')}'); } - // 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) + // 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 false; + 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) { @@ -184,7 +203,7 @@ class TxTracker { '[TX LOG] onCarpeaterDrop callback is ${onCarpeaterDrop != null ? "SET" : "NULL"}'); onCarpeaterDrop?.call( pathHex, 'RSSI too strong (${metadata.rssi} dBm)'); - return false; // Mark as handled (dropped) + return TxEchoResult.notEcho; } else { debugLog( '[TX LOG] ✓ RSSI OK (${metadata.rssi} < ${PacketValidator.maxRssiThreshold})'); @@ -194,7 +213,7 @@ class TxTracker { if (metadata.encryptedPayload.length < 3) { debugLog( '[TX LOG] Ignoring: payload too short to contain channel hash'); - return false; + return TxEchoResult.notEcho; } final packetChannelHash = metadata.channelHash!; @@ -204,35 +223,30 @@ 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'); - // 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...'); 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; + return TxEchoResult.notEcho; } final messageBytes = decryptedBytes.sublist(5); - // Convert bytes to string and strip null terminators var decryptedMessage = utf8.decode(messageBytes, allowMalformed: true); decryptedMessage = @@ -245,8 +259,6 @@ class TxTracker { 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); @@ -255,7 +267,7 @@ class TxTracker { '[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; + return TxEchoResult.notEcho; } if (decryptedMessage == originalPayload) { @@ -269,7 +281,7 @@ class TxTracker { } catch (e) { debugLog( '[MESSAGE_CORRELATION] ❌ REJECT: Failed to decrypt message: $e'); - return false; + return TxEchoResult.notEcho; } } else { debugWarn( @@ -278,20 +290,22 @@ class TxTracker { '[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 --- + + final targetMap = isMultiHop ? multiHopRepeaters : repeaters; + final echoType = isMultiHop ? 'multi-hop' : 'direct'; debugLog( - '[PING] Repeater echo accepted: first_hop=$pathHex, SNR=$reportedSnr, ' + '[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; @@ -299,48 +313,56 @@ class TxTracker { 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 ' '(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( + '[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; } } @@ -350,17 +372,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 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 2f5e71d..84949b0 100644 --- a/lib/services/meshcore/unified_rx_handler.dart +++ b/lib/services/meshcore/unified_rx_handler.dart @@ -95,9 +95,13 @@ class UnifiedRxHandler { // 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; } } diff --git a/lib/services/meshcore/wire_tag_codec.dart b/lib/services/meshcore/wire_tag_codec.dart new file mode 100644 index 0000000..f4cfd8a --- /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 0000000..c5f0e75 --- /dev/null +++ b/lib/services/offline_map_service.dart @@ -0,0 +1,918 @@ +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 761a315..78fead9 100644 --- a/lib/services/offline_session_service.dart +++ b/lib/services/offline_session_service.dart @@ -13,7 +13,14 @@ class OfflineSession { 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, @@ -23,7 +30,13 @@ class OfflineSession { 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 @@ -36,7 +49,14 @@ class OfflineSession { 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?, ); } @@ -50,21 +70,39 @@ class OfflineSession { '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, ); } @@ -157,6 +195,10 @@ class OfflineSessionService { 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'); @@ -174,6 +216,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( @@ -184,6 +230,10 @@ class OfflineSessionService { devicePublicKey: devicePublicKey, deviceName: deviceName, contactUri: contactUri, + radioConfig: radioConfig, + deviceModel: deviceModel, + powerLevel: powerLevel, + appVersion: appVersion, ); _sessions.insert(0, session); // Add at beginning (newest first) @@ -201,6 +251,10 @@ class OfflineSessionService { 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'); @@ -216,6 +270,22 @@ class OfflineSessionService { 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, @@ -225,6 +295,10 @@ class OfflineSessionService { devicePublicKey: devicePublicKey ?? existing.devicePublicKey, deviceName: deviceName ?? existing.deviceName, contactUri: contactUri ?? existing.contactUri, + radioConfig: effectiveRadioConfig, + deviceModel: effectiveDeviceModel, + powerLevel: effectivePowerLevel, + appVersion: effectiveAppVersion, ); await _saveSessions(); debugLog( @@ -243,6 +317,10 @@ class OfflineSessionService { devicePublicKey: devicePublicKey, deviceName: deviceName, contactUri: contactUri, + radioConfig: radioConfig, + deviceModel: deviceModel, + powerLevel: powerLevel, + appVersion: appVersion, ); // saveSession inserts at index 0 (newest first) if (_sessions.isNotEmpty) { @@ -261,19 +339,65 @@ class OfflineSessionService { } } - /// Mark a session as uploaded without deleting it - Future markAsUploaded(String filename) async { + /// 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/ping_service.dart b/lib/services/ping_service.dart index 0482f81..34c29d3 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -15,6 +15,7 @@ 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'; @@ -96,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 @@ -151,15 +154,32 @@ class PingService { /// 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; @@ -170,8 +190,11 @@ class PingService { 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 @@ -465,6 +488,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; @@ -566,11 +594,42 @@ class PingService { } final txPowerDbm = _connection.deviceModel?.txPower ?? 22; - // Build ping message (same format used for TxTracker correlation) - // Power is no longer included in the mesh message — sent per-ping in API payload + // 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. + // + // With a session: a keyed wire tag "MM:" (privacy default), or + // "MM::lat,lon" when Broadcast My Coordinates is on (tag + plaintext coords). + // No session yet: plaintext "MM:lat,lon" (no tag can be computed). final coordsStr = - '${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'; - final pingMessage = '@[MapperBot] $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 { + // No session yet → no tag can be computed; plaintext coords only (unchanged). + pingMessage = 'MM:$coordsStr'; + } // Capture noise floor at ping time final noiseFloor = _connection.lastNoiseFloor; @@ -645,6 +704,37 @@ 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, @@ -660,8 +750,8 @@ class PingService { // Play transmit sound immediately before sending _audioService?.playTransmitSound(); - // Send ping via BLE (coordinates only — power is in API payload) - await _connection.sendPing(position.latitude, position.longitude); + // Send ping via BLE (pre-composed body — wire tag or legacy coords) + await _connection.sendPing(pingMessage); // Mark ping time and position _lastTxTime = DateTime.now(); @@ -680,6 +770,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); @@ -753,11 +845,25 @@ class PingService { 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( @@ -768,9 +874,32 @@ class PingService { 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; diff --git a/lib/services/tile_cache_service.dart b/lib/services/tile_cache_service.dart new file mode 100644 index 0000000..ee9407e --- /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 0000000..f3a37a0 --- /dev/null +++ b/lib/services/transport/android_serial_service.dart @@ -0,0 +1,131 @@ +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 0000000..e3647ba --- /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 0000000..8532d51 --- /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 0000000..88cbaac --- /dev/null +++ b/lib/services/transport/stream_transport_base.dart @@ -0,0 +1,121 @@ +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 0000000..16f7105 --- /dev/null +++ b/lib/services/transport/tcp_service.dart @@ -0,0 +1,168 @@ +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 0000000..2f9d138 --- /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 0000000..514fcf9 --- /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 0000000..0ccc7db --- /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 0000000..161b046 --- /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/utils/coverage_summary.dart b/lib/utils/coverage_summary.dart new file mode 100644 index 0000000..a316042 --- /dev/null +++ b/lib/utils/coverage_summary.dart @@ -0,0 +1,899 @@ +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 0000000..16555fc --- /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/distance_formatter.dart b/lib/utils/distance_formatter.dart index 63cfc7d..d8fab1a 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 0000000..6151481 --- /dev/null +++ b/lib/utils/geo_validation.dart @@ -0,0 +1,72 @@ +// 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/mvt_cells.dart b/lib/utils/mvt_cells.dart new file mode 100644 index 0000000..1ded785 --- /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/repeater_format.dart b/lib/utils/repeater_format.dart new file mode 100644 index 0000000..692fe65 --- /dev/null +++ b/lib/utils/repeater_format.dart @@ -0,0 +1,66 @@ +/// 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/widgets/cell_summary_sheet.dart b/lib/widgets/cell_summary_sheet.dart new file mode 100644 index 0000000..4710fcf --- /dev/null +++ b/lib/widgets/cell_summary_sheet.dart @@ -0,0 +1,315 @@ +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/map_widget.dart b/lib/widgets/map_widget.dart index ab35995..6ccd661 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -1,41 +1,323 @@ +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 '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']; + +/// 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; } } @@ -45,6 +327,8 @@ extension MapStyleExtension on MapStyle { return 'Dark'; case MapStyle.light: return 'Light'; + case MapStyle.liberty: + return 'Liberty'; case MapStyle.satellite: return 'Satellite'; } @@ -56,58 +340,43 @@ 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 { - switch (this) { - case MapStyle.dark: - return 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; - case MapStyle.light: - return 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; - case MapStyle.satellite: - return 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'; - } - } - - List? get subdomains { + /// MapLibre style URL (or inline JSON for satellite) + String get styleUrl { switch (this) { case MapStyle.dark: - return ['a', 'b', 'c', 'd']; + return 'https://tiles.openfreemap.org/styles/dark'; case MapStyle.light: - return null; // OSM doesn't use subdomains anymore + return 'https://tiles.openfreemap.org/styles/bright'; + case MapStyle.liberty: + return 'https://tiles.openfreemap.org/styles/liberty'; case MapStyle.satellite: - return null; // ArcGIS doesn't use subdomains + return _satelliteStyleJson; } } - /// Whether this style supports retina tiles via {r} placeholder - bool get supportsRetina { + /// 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 true; // Carto supports @2x via {r} case MapStyle.light: - return false; // OSM has no retina support + case MapStyle.liberty: + return true; case MapStyle.satellite: - return false; // ArcGIS has no retina support + return false; } } -} -/// 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 - ), - ), - ); + /// Styles offered in the offline download picker. + static List get downloadable => + MapStyle.values.where((s) => s.isDownloadable).toList(); } /// Resolved repeater with SNR and ambiguity info for ping focus mode. @@ -121,8 +390,27 @@ class _ResolvedRepeater { 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 @@ -151,8 +439,37 @@ 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 @@ -163,15 +480,42 @@ class _MapWidgetState extends State with TickerProviderStateMixin { 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 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; @@ -181,6 +525,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Map navigation trigger tracking (from log screen) int _lastNavigationTrigger = 0; + // History session map view tracking + bool _wasViewingHistory = false; + // Ping focus mode — highlight connected repeaters when a marker is tapped LatLng? _focusedPingLocation; DateTime? _focusedPingTimestamp; @@ -189,207 +536,711 @@ class _MapWidgetState extends State with TickerProviderStateMixin { double? _preFocusZoom; bool _wasAutoFollowBeforeFocus = false; bool _wasRotatingBeforeFocus = false; // true if heading mode was active - - // 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; + // 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 _buildMap to detect slider changes and apply + // them live via _applyCoverageOverlayOpacity (no layer rebuild needed). + double? _lastAppliedCoverageOpacity; + // 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; + + // 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); + } + + 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); + } + + @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); + _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); - }); + // =========================================================================== + // 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!.forward(); + // 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, + ); } - /// 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; + /// 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; - // Cancel any running animation - _animationController?.stop(); - _animationController?.dispose(); + // 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; + } - // Create new animation controller - const duration = Duration(milliseconds: 500); // Smooth zoom + pan + // 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; + } - _animationController = AnimationController( - duration: duration, - vsync: this, - ); + final size = context.size ?? MediaQuery.of(context).size; + final pad = clampFitPadding( + leftPad, topPad, rightPad, bottomPad, size.width, size.height); - _animation = CurvedAnimation( - parent: _animationController!, - curve: Curves.easeInOutCubic, // Smooth acceleration and deceleration + _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), ); + } - _animationStartPosition = currentCenter; - _animationEndPosition = target; - - _animation!.addListener(() { - if (!mounted || - _animationStartPosition == null || - _animationEndPosition == null) { - return; + /// 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), + ); } - - // 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); - - // Interpolate zoom - final zoom = currentZoom + ((targetZoom - currentZoom) * t); - - _mapController.move(LatLng(lat, lng), zoom); - }); - - _animationController!.forward(); + } } - /// Zoom to fit a focused ping and its connected repeaters on screen - void _zoomToFocusBounds( - LatLng pingLocation, List<_ResolvedRepeater> repeaters) { - if (!_isMapReady || !mounted) return; - - final points = [ - pingLocation, - ...repeaters.map((r) => LatLng(r.repeater.lat, r.repeater.lon)) - ]; - if (points.length < 2) return; - - final fitted = CameraFit.coordinates( - coordinates: points, - padding: EdgeInsets.fromLTRB( - 60, 60, 60, MediaQuery.of(context).size.height * 0.4), - maxZoom: 15, - ).fit(_mapController.camera); + /// 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; + }); + } + } - _animateToPositionWithZoom(fitted.center, fitted.zoom); + /// 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; } @@ -400,214 +1251,344 @@ 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; + /// 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; + } - _rotationAnimation!.addListener(() { - if (!mounted || - _rotationStartAngle == null || - _rotationEndAngle == null) { - return; + // 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; } + } - // Interpolate between start and end angles - final t = _rotationAnimation!.value; - final rotation = _rotationStartAngle! + - ((_rotationEndAngle! - _rotationStartAngle!) * t); - - _mapController.rotate(rotation); - }); - - _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; + // 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 / - (256 * math.pow(2, zoom)) * + (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))); + // 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'); + }); } - // 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; - } + // 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); + 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'); } 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) { + _lastGpsSyncVersion = gpsVersion; + 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 && @@ -617,29 +1598,95 @@ class _MapWidgetState extends State with TickerProviderStateMixin { if (target != null) { // Reset map controls to default state _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(() {}); + } + } } }); } } + // (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 @@ -649,50 +1696,201 @@ class _MapWidgetState extends State with TickerProviderStateMixin { return Stack( children: [ - // Map - _buildMap(appState, center), + // 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(), + ), - // GPS Info + Top Repeaters overlay (top-left, respects dynamic island in landscape) - Positioned( - top: topPadding, - left: leftPadding, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildGpsInfoOverlay(appState), - if (appState.preferences.showTopRepeaters) ...[ - const SizedBox(height: 6), - _buildTopRepeatersOverlay(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 - top-right in both orientations, collapsible - Positioned( - top: topPadding, - right: 8, - child: _buildCollapsibleMapControls(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); - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Toggle button (always visible) - at top - GestureDetector( - onTap: onToggle, - child: Container( - padding: const EdgeInsets.all(10), + /// 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 @@ -706,180 +1904,3923 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), ), ), - // Map controls (only when expanded) - below the toggle button - if (isExpanded) _buildMapControls(appState), - ], - ); + // 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.) + + // Detect coverage overlay opacity change (user dragged the slider in + // Settings → General) and push it to the live raster layer without + // rebuilding the whole overlay. Skipped while ping focus mode is active — + // focus forces opacity to 0 and _dismissPingFocus restores the preference + // value directly — and while a tapped cell dims the backdrop (_clearCellHighlight + // restores it on sheet close), or this would un-dim it mid-sheet. + final wantedOpacity = appState.preferences.coverageOverlayOpacity; + if (_isMapReady && + _styleLoaded && + _focusedPingLocation == null && + !_coverageDimmedForCell && + !_coverageDimmedForRepeater && + _lastAppliedCoverageOpacity != null && + _lastAppliedCoverageOpacity != wantedOpacity) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _applyCoverageOverlayOpacity(wantedOpacity); + }); + } + + 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); + // Same idea for the GPS-only sync gate: _syncAllAnnotations already + // ran _syncGpsSymbol, so capture the current GPS version to keep the + // next build from scheduling a redundant updateSymbol call. + _lastGpsSyncVersion = Object.hash( + appState.currentPosition?.latitude, + appState.currentPosition?.longitude, + _computedHeading, + appState.preferences.gpsMarkerStyle, + ); + 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. + Future _addCoverageOverlay(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; + + 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 _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. + Future _removeCoverageOverlay() async { + final layerId = _activeCoverageLayerId; + final sourceId = _activeCoverageSourceId; + _activeCoverageLayerId = null; + _activeCoverageSourceId = 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. + Future _applyCoveragePatch(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 { + 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 — 6 styles + const gpsSize = Size(48, 48); + final gpsPainters = { + 'arrow': const _ArrowPainter(), + 'car': const _CarMarkerPainter(), + 'bike': const _BikeMarkerPainter(), + 'boat': const _BoatMarkerPainter(), + 'walk': const _WalkMarkerPainter(), + '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()); + } 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), + ); + } catch (e) { + debugError('[MAP] update gps puck failed: $e'); + } + } + + /// 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]. + Future _ensureGpsPuckLayer() async { + if (_gpsPuckLayerInstalled) return true; + if (_mapController == null) return false; + try { + try { + await _mapController!.removeLayer(_gpsPuckLayerId); + } catch (_) {} + try { + await _mapController!.removeSource(_gpsPuckSourceId); + } catch (_) {} + await _mapController! + .addGeoJsonSource(_gpsPuckSourceId, _emptyFeatureCollection()); + 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, + ); + _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; + } } - 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); - } + /// 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], ), - children: [ - // Tile layer (dynamic based on selected style from preferences) - // Skipped entirely when map tiles are disabled to save mobile data - if (appState.preferences.mapTilesEnabled) - 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), - tileDisplay: const TileDisplay.fadeIn( - reloadStartOpacity: 1.0, - ), - tileProvider: SilentCancellableNetworkTileProvider(), - ); - }, - ), + ]; + // 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; + } - // MeshMapper coverage overlay (only when zone code available, overlay enabled, and tiles enabled) - if (appState.preferences.mapTilesEnabled && - appState.zoneCode != null && - _showMeshMapperOverlay) - TileLayer( - urlTemplate: - 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}${appState.preferences.colorVisionType != 'none' ? '&cvd=${appState.preferences.colorVisionType}' : ''}', - userAgentPackageName: 'com.meshmapper.app', - minZoom: 3, - maxZoom: 17, - tileDisplay: const TileDisplay.fadeIn( - reloadStartOpacity: 1.0, - ), - tileProvider: SilentCancellableNetworkTileProvider(), - ), + /// 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]]); + } - // Coverage markers (TX, RX, DISC, Trace) — sorted by timestamp, newest on top - // During focus mode, the focused marker is excluded and rendered in its own top layer - MarkerLayer( - markers: _buildCoverageMarkers( - txPings: appState.txPings, - rxPings: appState.rxPings, - discEntries: appState.discLogEntries, - discDropEnabled: appState.discDropEnabled, - traceEntries: appState.traceLogEntries, - excludeFocused: _focusedPingLocation != null, - ), - ), + features.add({ + 'type': 'Feature', + 'properties': { + 'iata': code, + 'label': '$code BOUNDARY', + }, + 'geometry': { + 'type': 'Polygon', + 'coordinates': [ring], + }, + }); + } - // Focus mode: polylines from focused ping to each connected repeater - // Line color = SNR (green/yellow/red). Ambiguous matches get a white border. - if (_focusedPingLocation != null && _focusedRepeaters.isNotEmpty) - PolylineLayer( - polylines: _focusedRepeaters.map((r) { - final lineColor = - r.snr != null ? PingColors.snrColor(r.snr!) : Colors.grey; - return Polyline( - points: [ - _focusedPingLocation!, - LatLng(r.repeater.lat, r.repeater.lon) - ], - color: lineColor.withValues(alpha: 0.9), - strokeWidth: 3.5, - isDotted: true, - borderStrokeWidth: r.ambiguous ? 1.5 : 0, - borderColor: - r.ambiguous ? Colors.white.withValues(alpha: 0.6) : null, - ); - }).toList(), - ), + if (features.isEmpty) return; - // Repeater markers (magenta with ID, rotate with map) - // During focus mode, split into two layers: faded repeaters below, connected on top - if (_focusedPingLocation != null && _focusedRepeaters.isNotEmpty) ...[ - // Faded non-connected repeaters (below) - MarkerLayer( - rotate: true, - markers: _buildRepeaterMarkers( - appState.repeaters, - appState.enforceHopBytes ? appState.effectiveHopBytes : null, - onlyFaded: true, - ), - ), - // Distance labels (middle) - MarkerLayer( - rotate: true, - markers: _buildFocusDistanceLabels(appState), - ), - // Connected repeaters (on top) - MarkerLayer( - rotate: true, - markers: _buildRepeaterMarkers( - appState.repeaters, - appState.enforceHopBytes ? appState.effectiveHopBytes : null, - onlyConnected: true, - ), - ), - // Focused ping marker (above everything except GPS) - MarkerLayer( - markers: _buildFocusedPingMarker( - txPings: appState.txPings, - rxPings: appState.rxPings, - discEntries: appState.discLogEntries, - discDropEnabled: appState.discDropEnabled, - traceEntries: appState.traceLogEntries, - ), - ), - ] else - // Normal mode: single layer with all repeaters - MarkerLayer( - rotate: true, - markers: _buildRepeaterMarkers( - appState.repeaters, - appState.enforceHopBytes ? appState.effectiveHopBytes : null, - ), - ), + final geojson = { + 'type': 'FeatureCollection', + 'features': features, + }; - // Current position marker - if (appState.currentPosition != null) - MarkerLayer( - // Vehicle/boat icons stay upright by counter-rotating against map rotation; - // arrow, walk, and chomper rotate with heading (handled by Transform.rotate in the painter) - rotate: appState.preferences.gpsMarkerStyle != 'arrow' && - appState.preferences.gpsMarkerStyle != 'walk' && - appState.preferences.gpsMarkerStyle != 'chomper', - markers: [ - Marker( - point: LatLng( - appState.currentPosition!.latitude, - appState.currentPosition!.longitude, - ), - width: 48, - height: 48, - child: _buildCurrentPositionMarker( - appState.currentPosition!.heading), - ), - ], - ), + 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, + ); + } + /// Color for the overlay ping-type dot static Color _overlayTypeColor(OverlayPingType type) { return switch (type) { @@ -1062,74 +6003,123 @@ class _MapWidgetState extends State with TickerProviderStateMixin { 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); + // 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( color: Colors.black.withValues(alpha: 0.7), // 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, ); } @@ -1184,6 +6174,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { if (_autoFollow) { setState(() { _autoFollow = false; + _autoFollowDesiredZoom = null; }); appState.setMapAutoFollow(false); return; @@ -1195,16 +6186,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, + ); } } @@ -1212,6 +6218,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() { @@ -1220,53 +6264,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); } }); } @@ -1277,47 +6293,19 @@ class _MapWidgetState extends State with TickerProviderStateMixin { void _toggleRotationLock() { final appState = context.read(); setState(() { - _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, - ); - - _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); - }); + _rotationLocked = !_rotationLocked; - _rotationAnimationController!.forward(); + // When enabling lock in "Always North" mode, rotate back to north + 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), + ); } } }); @@ -1733,6 +6721,19 @@ class _MapWidgetState extends State with TickerProviderStateMixin { 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) @@ -1965,115 +6966,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } /// Build a coverage marker child widget based on the user's marker style preference. - Widget _buildCoverageMarkerChild(Color color) { - final style = context.read().preferences.markerStyle; - switch (style) { - case 'circle': - return Container( - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2.0), - boxShadow: const [ - BoxShadow( - color: Colors.black12, blurRadius: 2, offset: Offset(0, 1)) - ], - ), - ); - case 'pin': - return CustomPaint( - size: const Size(20, 20), - painter: _PinMarkerPainter(color), - ); - case 'diamond': - return CustomPaint( - size: const Size(20, 20), - painter: _DiamondMarkerPainter(color), - ); - case 'dot': - default: - return Container( - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - border: Border.all( - color: Colors.white.withValues(alpha: 0.6), width: 1.5), - boxShadow: const [ - BoxShadow( - color: Colors.black12, blurRadius: 2, offset: Offset(0, 1)) - ], - ), - ); - } - } - - /// Build all coverage dot markers sorted by timestamp (oldest first = drawn underneath). - /// Newer pings always render on top regardless of type. - List _buildCoverageMarkers({ - required List txPings, - required List rxPings, - required List discEntries, - required bool discDropEnabled, - required List traceEntries, - bool excludeFocused = false, - }) { - final timestamped = <(DateTime, Marker)>[ - for (final ping in txPings) - if (!excludeFocused || - !_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) - (ping.timestamp, _buildTxMarker(ping)), - for (final ping in rxPings) - if (!excludeFocused || - !_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) - (ping.timestamp, _buildRxMarker(ping)), - for (final entry in discEntries) - if (!excludeFocused || - !_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) - (entry.timestamp, _buildDiscMarker(entry, discDropEnabled)), - for (final entry in traceEntries) - if (!excludeFocused || - !_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) - (entry.timestamp, _buildTraceMarker(entry)), - ]; - - timestamped.sort((a, b) => a.$1.compareTo(b.$1)); - return timestamped.map((e) => e.$2).toList(); - } - - /// Build just the focused ping marker for rendering in its own top layer. - List _buildFocusedPingMarker({ - required List txPings, - required List rxPings, - required List discEntries, - required bool discDropEnabled, - required List traceEntries, - }) { - if (_focusedPingLocation == null) return []; - - for (final ping in txPings) { - if (_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) { - return [_buildTxMarker(ping)]; - } - } - for (final ping in rxPings) { - if (_isFocusedPing(ping.latitude, ping.longitude, ping.timestamp)) { - return [_buildRxMarker(ping)]; - } - } - for (final entry in discEntries) { - if (_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) { - return [_buildDiscMarker(entry, discDropEnabled)]; - } - } - for (final entry in traceEntries) { - if (_isFocusedPing(entry.latitude, entry.longitude, entry.timestamp)) { - return [_buildTraceMarker(entry)]; - } - } - return []; - } - /// 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 && @@ -2081,97 +6976,33 @@ class _MapWidgetState extends State with TickerProviderStateMixin { _focusedPingLocation!.longitude == lon; } - /// Apply focus fade to a marker color. Returns dimmed color if focus is active - /// and this marker is not the focused one. - Color _applyFocusFade(Color color, bool isFocused) { - if (_focusedPingLocation == null || isFocused) return color; - return color.withValues(alpha: 0.15); - } - - Marker _buildTxMarker(TxPing ping) { - final isFocused = - _isFocusedPing(ping.latitude, ping.longitude, ping.timestamp); - final color = - ping.heardRepeaters.isEmpty ? PingColors.txFail : PingColors.txSuccess; - final size = isFocused ? 24.0 : 20.0; - return Marker( - point: LatLng(ping.latitude, ping.longitude), - width: size, - height: size, - child: GestureDetector( - onTap: () => _showTxPingDetails(ping), - child: _buildCoverageMarkerChild(_applyFocusFade(color, isFocused)), - ), - ); - } - - Marker _buildRxMarker(RxPing ping) { - final isFocused = - _isFocusedPing(ping.latitude, ping.longitude, ping.timestamp); - final size = isFocused ? 24.0 : 20.0; - return Marker( - point: LatLng(ping.latitude, ping.longitude), - width: size, - height: size, - child: GestureDetector( - onTap: () => _showRxPingDetails(ping), - child: _buildCoverageMarkerChild( - _applyFocusFade(PingColors.rx, isFocused)), - ), - ); - } - - Marker _buildDiscMarker(DiscLogEntry entry, bool discDropEnabled) { - final isFocused = - _isFocusedPing(entry.latitude, entry.longitude, entry.timestamp); - final color = entry.nodeCount == 0 - ? (discDropEnabled ? PingColors.txFail : PingColors.discFail) - : _discMarkerColor; - final size = isFocused ? 24.0 : 20.0; - return Marker( - point: LatLng(entry.latitude, entry.longitude), - width: size, - height: size, - child: GestureDetector( - onTap: () => _showDiscPingDetails(entry), - child: _buildCoverageMarkerChild(_applyFocusFade(color, isFocused)), - ), - ); - } - - Marker _buildTraceMarker(TraceLogEntry entry) { - final isFocused = - _isFocusedPing(entry.latitude, entry.longitude, entry.timestamp); - final color = entry.success ? Colors.cyan : Colors.grey; - final size = isFocused ? 24.0 : 20.0; - return Marker( - point: LatLng(entry.latitude, entry.longitude), - width: size, - height: size, - child: GestureDetector( - onTap: () => _showTraceDetails(entry), - child: _buildCoverageMarkerChild(_applyFocusFade(color, isFocused)), - ), - ); - } - - void _showTraceDetails(TraceLogEntry entry) { - // Activate focus mode for successful traces with a known repeater - if (entry.success) { - final resolved = _resolveRepeatersByHexIds( - [entry.targetRepeaterId], - snrValues: [entry.localSnr], - ); - if (resolved.isNotEmpty) { - _activatePingFocus( - LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); - } + 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)), @@ -2228,6 +7059,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), @@ -2288,8 +7127,16 @@ class _MapWidgetState extends State with TickerProviderStateMixin { if (entry.success) ...[ const SizedBox(height: 12), - // Table with headers - Container( + // 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), @@ -2308,7 +7155,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { child: Row( children: [ SizedBox( - width: _nodeColumnWidth(), + width: nodeColWidth, child: Text( 'Node', style: TextStyle( @@ -2386,10 +7233,26 @@ class _MapWidgetState extends State with TickerProviderStateMixin { horizontal: 12, vertical: 8), child: Row( children: [ - RepeaterIdChip( - repeaterId: entry.targetRepeaterId, - fontSize: 13, - width: _nodeColumnWidth()), + // 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( @@ -2424,61 +7287,21 @@ class _MapWidgetState extends State with TickerProviderStateMixin { }), ], ), - ), + ); + }), ], ], ), ), ), ), - ).whenComplete(() => _dismissPingFocus()); - } - - /// Build distance label markers at the midpoint of each focus line. - List _buildFocusDistanceLabels(AppStateProvider appState) { - if (_focusedPingLocation == null) return []; - final isImperial = appState.preferences.isImperial; - final ping = _focusedPingLocation!; - - return _focusedRepeaters.map((r) { - final repeaterPos = LatLng(r.repeater.lat, r.repeater.lon); - // Midpoint of the line - final midLat = (ping.latitude + repeaterPos.latitude) / 2; - final midLon = (ping.longitude + repeaterPos.longitude) / 2; - // Distance in meters — use GpsService for consistency with repeater popup - final meters = GpsService.distanceBetween( - ping.latitude, - ping.longitude, - repeaterPos.latitude, - repeaterPos.longitude, - ); - final label = meters < 1000 - ? formatMeters(meters, isImperial: isImperial) - : formatKilometers(meters / 1000, isImperial: isImperial); - - return Marker( - point: LatLng(midLat, midLon), - width: 70, - height: 22, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - label, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - color: Colors.white, - ), - ), - ), - ); - }).toList(); + ).then((result) { + if (result == 'minimized') { + setState(() => _focusPanelMinimized = true); + } else { + _dismissPingFocus(); + } + }); } /// DISC marker color (delegates to active palette) @@ -2523,79 +7346,684 @@ class _MapWidgetState extends State with TickerProviderStateMixin { 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) { - _preFocusCenter = _mapController.camera.center; - _preFocusZoom = _mapController.camera.zoom; - _wasAutoFollowBeforeFocus = _autoFollow; - _wasRotatingBeforeFocus = !_alwaysNorth; - - if (_autoFollow) { - _autoFollow = false; + // 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; } - // Lock to north-up during focus so the zoom-to-fit view is stable - if (!_alwaysNorth) { - _alwaysNorth = true; - _animateToRotation(0); // Won't fire because _alwaysNorth is now true - // Snap rotation to 0 directly - if (_isMapReady) { - _mapController.rotate(0); - } + 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(() { - _focusedPingLocation = pingLocation; _focusedPingTimestamp = timestamp; - _focusedRepeaters = repeaters; + _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) { - _zoomToFocusBounds(pingLocation, repeaters); + _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), + ), + ), + ], + ); + } + + /// 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; + final range = loading + ? '…' + : (stats?.maxRangeMeters != null + ? formatCoverageDistance(stats!.maxRangeMeters!, + isImperial: isImperial) + : '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), + ], + ); + }, + ); + } + + /// 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(); + }, + )); + } + + 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), + ], + ), + ), + ); } - /// Dismiss ping focus mode — restore map state. - void _dismissPingFocus() { - if (_focusedPingLocation == null || !mounted) return; - - final center = _preFocusCenter; - final zoom = _preFocusZoom; - final shouldRestoreAutoFollow = _wasAutoFollowBeforeFocus && !_autoFollow; - final shouldRestoreRotation = _wasRotatingBeforeFocus && _alwaysNorth; + /// 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), + ); - // Clear focus state but do NOT restore auto-follow or rotation yet — - // they would immediately trigger animations in the build method that - // override our zoom-back animation (both share _animationController). - setState(() { - _focusedPingLocation = null; - _focusedPingTimestamp = null; - _focusedRepeaters = []; - }); + 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!)); + } + } - if (center != null && zoom != null) { - _animateToPositionWithZoom(center, zoom); + if (chips.isEmpty) return const SizedBox.shrink(); + return Wrap(spacing: 14, runSpacing: 6, children: chips); + } - // Restore auto-follow and heading rotation after the zoom-back - // animation completes (500ms) so they don't clobber it mid-flight. - if (shouldRestoreAutoFollow || shouldRestoreRotation) { - Future.delayed(const Duration(milliseconds: 550), () { - if (mounted) { - setState(() { - if (shouldRestoreAutoFollow) _autoFollow = true; - if (shouldRestoreRotation) _alwaysNorth = false; - }); - } - }); - } - } else { - setState(() { - if (shouldRestoreAutoFollow) _autoFollow = true; - if (shouldRestoreRotation) _alwaysNorth = false; - }); + 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, + )); } } @@ -2607,6 +8035,14 @@ class _MapWidgetState extends State with TickerProviderStateMixin { 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) @@ -2619,136 +8055,6 @@ class _MapWidgetState extends State with TickerProviderStateMixin { return _repeaterMarkerColor; // Active (default) } - List _buildRepeaterMarkers( - List repeaters, - int? regionHopBytesOverride, { - bool onlyFaded = false, - bool onlyConnected = false, - }) { - final duplicateIds = _getDuplicateRepeaterIds(repeaters); - final hasFocus = _focusedPingLocation != null; - - return repeaters.where((repeater) { - if (!hasFocus) return true; // No focus — include all - final isConnected = - _focusedRepeaters.any((r) => r.repeater.id == repeater.id); - if (onlyConnected) return isConnected; - if (onlyFaded) return !isConnected; - return true; - }).map((repeater) { - final isDuplicate = duplicateIds.contains(repeater.id); - final markerColor = _getRepeaterMarkerColor(repeater, isDuplicate); - - // During focus mode, fade repeaters not connected to the focused ping - final isConnected = hasFocus && - _focusedRepeaters.any((r) => r.repeater.id == repeater.id); - final effectiveColor = (hasFocus && !isConnected) - ? markerColor.withValues(alpha: 0.15) - : markerColor; - final effectiveBorderColor = (hasFocus && !isConnected) - ? Colors.white.withValues(alpha: 0.15) - : Colors.white; - final effectiveTextColor = (hasFocus && !isConnected) - ? Colors.white.withValues(alpha: 0.15) - : Colors.white; - - // 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; - - // 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: effectiveColor, - borderRadius: borderRadius, - border: Border.all(color: effectiveBorderColor, width: 2), - boxShadow: (hasFocus && !isConnected) - ? null - : const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), - alignment: Alignment.center, - child: Text( - displayId, - style: TextStyle( - fontSize: displayId.length > 4 - ? 8 - : isLongId - ? 9 - : 10, - fontWeight: FontWeight.bold, - color: effectiveTextColor, - 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); - final style = context.read().preferences.gpsMarkerStyle; - - // Arrow, walk, and chomper rotate with heading; vehicle/boat icons don't (they face up) - final shouldRotate = - style == 'arrow' || style == 'walk' || style == 'chomper'; - - final CustomPainter painter; - switch (style) { - case 'car': - painter = const _CarMarkerPainter(); - case 'bike': - painter = const _BikeMarkerPainter(); - case 'boat': - painter = const _BoatMarkerPainter(); - case 'walk': - painter = const _WalkMarkerPainter(); - case 'chomper': - painter = const _ChomperMarkerPainter(); - case 'arrow': - default: - painter = const _ArrowPainter(); - } - - final child = CustomPaint(size: const Size(24, 24), painter: painter); - return shouldRotate - ? Transform.rotate(angle: headingRadians, child: child) - : child; - } - /// 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}) { @@ -2766,27 +8072,114 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } } + /// 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; + + final appState = context.read(); + final regionOverride = + appState.enforceHopBytes ? appState.effectiveHopBytes : null; + + 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, + )), + ], + ), + ), + ), + ), + ); + } + /// 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; - // Activate focus mode if the ping was heard by known repeaters - if (heardRepeaters.isNotEmpty) { - final resolved = _resolveRepeatersByHexIds( - heardRepeaters.map((r) => r.repeaterId).toList(), - snrValues: heardRepeaters.map((r) => r.snr).toList(), - ); - if (resolved.isNotEmpty) { - _activatePingFocus( - LatLng(ping.latitude, ping.longitude), ping.timestamp, resolved); - } + // 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)), @@ -2844,6 +8237,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), @@ -2889,150 +8290,394 @@ 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( + 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 - .outline - .withValues(alpha: 0.5)), + .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( - children: [ - // Header row - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 10), - child: Row( - children: [ - SizedBox( - width: _nodeColumnWidth(), - 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, - ), + 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: [ + 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(), ), - ), ], ), ), - Divider( - height: 1, color: Theme.of(context).dividerColor), - // Data rows - ...heardRepeaters.map((repeater) { - final snrColor = repeater.snr != null - ? PingColors.snrColor(repeater.snr!) - : Colors.grey; - final rssiColor = repeater.rssi != null - ? PingColors.rssiColor(repeater.rssi!) - : Colors.grey; - - 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: [ - // Repeater ID - RepeaterIdChip( - repeaterId: repeater.repeaterId, - fontSize: 13, - width: _nodeColumnWidth()), - // 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), + ), ], ), ), - ], ], - ), - ), - ), + ); + }), + ], ), - ).whenComplete(() => _dismissPingFocus()); + ); } /// Show RX ping details popup - void _showRxPingDetails(RxPing ping) { + void _showRxPingDetails(RxPing ping, {bool fromMinimized = false}) { final snrColor = PingColors.snrColor(ping.snr); final rssiColor = PingColors.rssiColor(ping.rssi); @@ -3041,14 +8686,25 @@ class _MapWidgetState extends State with TickerProviderStateMixin { [ping.repeaterId], snrValues: [ping.snr], ); - if (resolved.isNotEmpty) { + + // 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)), @@ -3067,13 +8723,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( @@ -3096,6 +8752,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), @@ -3151,8 +8815,15 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), const SizedBox(height: 12), - // Repeater table (single row) - Container( + // 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), @@ -3171,7 +8842,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { child: Row( children: [ SizedBox( - width: _nodeColumnWidth(), + width: nodeColWidth, child: Text( 'Node', style: TextStyle( @@ -3223,11 +8894,24 @@ class _MapWidgetState extends State with TickerProviderStateMixin { horizontal: 12, vertical: 8), child: Row( children: [ - // Repeater ID - RepeaterIdChip( - repeaterId: ping.repeaterId, - fontSize: 13, - width: _nodeColumnWidth()), + // 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(), + ), + ], + ), + ), // SNR Expanded( child: Center( @@ -3252,33 +8936,84 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), ], ), - ), + ); + }), + + // 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, + ), + ), + ], ], ), ), - ).whenComplete(() => _dismissPingFocus()); + ).then((result) { + if (result == 'minimized') { + setState(() => _focusPanelMinimized = true); + } else { + _dismissPingFocus(); + } + }); } /// Show DISC ping details popup - void _showDiscPingDetails(DiscLogEntry entry) { - // Activate focus mode for discovered nodes with known repeater positions - if (entry.discoveredNodes.isNotEmpty) { - final resolved = _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(), - ); - if (resolved.isNotEmpty) { - _activatePingFocus( - LatLng(entry.latitude, entry.longitude), entry.timestamp, resolved); - } + 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)), @@ -3336,6 +9071,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), @@ -3396,8 +9139,15 @@ class _MapWidgetState extends State with TickerProviderStateMixin { if (entry.discoveredNodes.isNotEmpty) ...[ const SizedBox(height: 12), - // Table with headers - Container( + // 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), @@ -3416,7 +9166,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { child: Row( children: [ SizedBox( - width: _nodeColumnWidth(extraPadding: 20), + width: + _nodeColumnWidth(extraPadding: nodeExtra), child: Text( 'Node', style: TextStyle( @@ -3479,6 +9230,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { PingColors.rssiColor(node.localRssi); final txSnrColor = PingColors.snrColor(node.remoteSnr.toDouble()); + final lacksLocation = + _hexIdLacksLocation(node.repeaterId); return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup( @@ -3493,9 +9246,10 @@ class _MapWidgetState extends State with TickerProviderStateMixin { horizontal: 12, vertical: 8), child: Row( children: [ - // Node ID with type + // Node ID with type (+ optional no-loc icon) SizedBox( - width: _nodeColumnWidth(extraPadding: 20), + width: + _nodeColumnWidth(extraPadding: nodeExtra), child: Row( children: [ RepeaterIdChip( @@ -3509,6 +9263,12 @@ class _MapWidgetState extends State with TickerProviderStateMixin { color: _discMarkerColor, ), ), + if (lacksLocation) + Padding( + padding: const EdgeInsets.only( + left: 4), + child: _noLocationIndicator(), + ), ], ), ), @@ -3547,14 +9307,21 @@ class _MapWidgetState extends State with TickerProviderStateMixin { }), ], ), - ), + ); + }), ], ], ), ), ), ), - ).whenComplete(() => _dismissPingFocus()); + ).then((result) { + if (result == 'minimized') { + setState(() => _focusPanelMinimized = true); + } else { + _dismissPingFocus(); + } + }); } /// Build a status chip for the repeater popup @@ -3577,29 +9344,148 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } - /// Show repeater details popup + /// 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 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) { + 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)), @@ -3607,12 +9493,16 @@ class _MapWidgetState extends State with TickerProviderStateMixin { 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( + // 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) { @@ -3654,6 +9544,14 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), ), 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), @@ -3691,51 +9589,190 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), child: Column( 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)}', - style: TextStyle( + // 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', - color: Theme.of(context).colorScheme.onSurface, - ), - ), + fontWeight: FontWeight.w600), ), - ], - ), - const SizedBox(height: 10), - // Last heard row - Row( - children: [ - Icon(Icons.access_time, - size: 16, + TextSpan( + text: ' ($fingerprintFull)', + style: TextStyle( + fontSize: 12, color: - Theme.of(context).colorScheme.onSurfaceVariant), - const SizedBox(width: 8), - Expanded( - child: Text( - repeater.lastHeardFormatted, - style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurface, - ), + 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), + ), + ), + // 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; + final range = loading + ? '…' + : (stats?.maxRangeMeters != null + ? formatCoverageDistance(stats!.maxRangeMeters!, + isImperial: isImperial) + : '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; + String v(int? n) => loading ? '…' : '${n ?? 0}'; + return 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)), + ], + ); + }, + ), ], ), + ), + ), + ).then((result) { + if (!mounted) return; + 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. + 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)), + ], ), ); } @@ -4211,6 +10248,133 @@ class _DiamondMarkerPainter extends CustomPainter { 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; diff --git a/lib/widgets/noise_floor_chart.dart b/lib/widgets/noise_floor_chart.dart index 77510bc..312d6a1 100644 --- a/lib/widgets/noise_floor_chart.dart +++ b/lib/widgets/noise_floor_chart.dart @@ -6,6 +6,7 @@ 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 { @@ -239,6 +240,7 @@ class InteractiveNoiseFloorChartState 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', @@ -249,6 +251,8 @@ class InteractiveNoiseFloorChartState 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', @@ -357,79 +361,10 @@ class InteractiveNoiseFloorChartState ], ), - // Repeaters section (table format like TX log) + // Repeaters section — split into direct and multi-hop 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)), - ], - ), - ), + ..._buildMarkerRepeaterSections(context, marker.repeaters!), ], // View on Map button @@ -525,6 +460,152 @@ class InteractiveNoiseFloorChartState ); } + /// 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)), + ], + ), + ), + ]); + } + + // 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); diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index f080ac0..5aa4560 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -7,13 +7,93 @@ 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 { const PingControls({super.key}); @override Widget build(BuildContext context) { - final appState = context.watch(); + // 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) @@ -32,6 +112,8 @@ class PingControls extends StatelessWidget { 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; @@ -97,13 +179,15 @@ class PingControls extends StatelessWidget { } // Note: cooldown and tooClose are shown on button itself + final floodTrafficVisible = appState.floodTrafficEnabled; + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Action buttons row Row( children: [ - if (!txNotAllowed) ...[ + 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 @@ -207,6 +291,7 @@ class PingControls extends StatelessWidget { : const Color(0xFF6366F1), // indigo-500 enabled: !isPendingDisable && !isTargetedRunning && + !isAutoStarting && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && @@ -266,6 +351,7 @@ class PingControls extends StatelessWidget { !isTxModeRunning && !isTargetedRunning && !isPendingDisable && + !isAutoStarting && !isPingSending && !rxWindowActive && !cooldownActive && @@ -313,6 +399,8 @@ class PingControls extends StatelessWidget { ), ], ); + }, + ); } Future _sendPing( @@ -373,45 +461,7 @@ 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; @@ -421,14 +471,13 @@ class _ActionButtonState extends State<_ActionButton> final effectiveColor = showColor ? widget.color : colorScheme.onSurfaceVariant; final borderOpacity = widget.isActive ? 0.6 : 0.3; + // 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 AnimatedBuilder( - animation: _pulseAnimation, - builder: (context, child) { - // Use animated opacity for active state background - final bgOpacity = widget.isActive ? _pulseAnimation.value : 0.12; - - return Material( + return Material( color: Colors.transparent, child: InkWell( onTap: widget.enabled ? widget.onPressed : null, @@ -527,8 +576,6 @@ class _ActionButtonState extends State<_ActionButton> ), ), ); - }, - ); } } @@ -591,10 +638,15 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { @override Widget build(BuildContext context) { - final appState = context.watch(); + // 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; + return ListenableBuilder( + listenable: appState.timerListenable, + builder: (_, __) { final isTargetedRunning = appState.isTargetedModeRunning; final maxLen = appState.traceHopBytes * 2; - final colorScheme = Theme.of(context).colorScheme; // Sync controller when provider clears target (e.g. trace bytes changed) if (appState.targetRepeaterId == null && _controller.text.isNotEmpty) { @@ -767,6 +819,8 @@ class _TargetedPingSectionState extends State<_TargetedPingSection> { ], ), ); + }, + ); } } @@ -803,7 +857,12 @@ class _CompactPingControlsState extends State { @override Widget build(BuildContext context) { - final appState = context.watch(); + // 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; @@ -820,6 +879,7 @@ class _CompactPingControlsState extends State { 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 @@ -906,6 +966,7 @@ class _CompactPingControlsState extends State { final activeModeEnabled = !isPendingDisable && !isTargetedRunning && + !isAutoStarting && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && @@ -922,6 +983,7 @@ class _CompactPingControlsState extends State { !isTxModeRunning && !isTargetedRunning && !isPendingDisable && + !isAutoStarting && !isPingSending && !rxWindowActive && !cooldownActive && @@ -1100,13 +1162,15 @@ class _CompactPingControlsState extends State { }, ); + 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) ...[ + if (!txNotAllowed && floodTrafficVisible) ...[ // Send Ping - expanded buttons stay big even when grey (cooldown) if (sendPingExpanded) Expanded(child: sendPingButton) @@ -1146,6 +1210,8 @@ class _CompactPingControlsState extends State { ], ], ); + }, + ); } /// Get label for Send Ping button @@ -1362,7 +1428,12 @@ class LandscapePingControls extends StatelessWidget { @override Widget build(BuildContext context) { - final appState = context.watch(); + // 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; @@ -1379,6 +1450,7 @@ class LandscapePingControls extends StatelessWidget { 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 @@ -1404,6 +1476,8 @@ class LandscapePingControls extends StatelessWidget { prefs.powerLevelSet || appState.deviceModel != null; + final floodTrafficVisible = appState.floodTrafficEnabled; + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -1420,7 +1494,7 @@ class LandscapePingControls extends StatelessWidget { // Action buttons row (icon-only) Row( children: [ - if (!txNotAllowed) ...[ + if (!txNotAllowed && floodTrafficVisible) ...[ // TX Ping button Expanded( child: _LandscapeIconButton( @@ -1471,6 +1545,7 @@ class LandscapePingControls extends StatelessWidget { : const Color(0xFF6366F1), // indigo-500 enabled: !isPendingDisable && !isTargetedRunning && + !isAutoStarting && ((isTxModeRunning || (canStartAuto && !isPassiveModeRunning && @@ -1515,6 +1590,7 @@ class LandscapePingControls extends StatelessWidget { !isTxModeRunning && !isTargetedRunning && !isPendingDisable && + !isAutoStarting && !isPingSending && !rxWindowActive && !cooldownActive && @@ -1547,6 +1623,8 @@ class LandscapePingControls extends StatelessWidget { ), ], ); + }, + ); } Future _sendPing( @@ -1743,57 +1821,22 @@ 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; + // 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 AnimatedBuilder( - animation: _pulseAnimation, - builder: (context, child) { - final bgOpacity = widget.isActive ? _pulseAnimation.value : 0.10; - - return Tooltip( + return Tooltip( message: widget.tooltip, child: Material( color: Colors.transparent, @@ -1866,8 +1909,6 @@ class _LandscapeIconButtonState extends State<_LandscapeIconButton> ), ), ); - }, - ); } } @@ -1900,44 +1941,7 @@ 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; @@ -1946,13 +1950,11 @@ class _CompactActionButtonState extends State<_CompactActionButton> 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( + return Material( color: Colors.transparent, child: InkWell( onTap: widget.enabled ? widget.onPressed : null, @@ -2043,7 +2045,5 @@ class _CompactActionButtonState extends State<_CompactActionButton> ), ), ); - }, - ); } } diff --git a/lib/widgets/repeater_id_chip.dart b/lib/widgets/repeater_id_chip.dart index 90296d0..aeedbb0 100644 --- a/lib/widgets/repeater_id_chip.dart +++ b/lib/widgets/repeater_id_chip.dart @@ -23,11 +23,15 @@ 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 @@ -39,36 +43,65 @@ class RepeaterIdChip extends StatelessWidget { ? fontSize - 1.0 // 4-char IDs (2-byte) : fontSize; // 2-char IDs (1-byte) - final child = Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - repeaterId, - softWrap: false, - overflow: TextOverflow.clip, - 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) + 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]. @@ -85,18 +118,91 @@ class RepeaterIdChip extends StatelessWidget { /// (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) { + content = Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + 'Repeater data not available', + style: TextStyle( + fontStyle: FontStyle.italic, + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 13, + ), + ), + ); + } else { + final matches = repeaters + .where( + (r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) + .toList(); + + if (matches.isEmpty) { + content = Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + 'Unknown repeater', + style: TextStyle( + fontStyle: FontStyle.italic, + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 13, + ), + ), + ); + } 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 a reference point is available + if (refLat != null && refLon != null) { + matches.sort((a, b) { + 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; + content = Column( + mainAxisSize: MainAxisSize.min, + children: matches + .map((r) => buildRepeaterRow(context, r, + refLat: refLat, + refLon: refLon, + regionHopBytesOverride: regionOverride)) + .toList(), + ); + } + } + showDialog( context: context, builder: (dialogContext) => Dialog( - backgroundColor: - Theme.of(dialogContext).colorScheme.surfaceContainerHighest, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), side: BorderSide( - color: Theme.of(dialogContext) - .colorScheme - .outline - .withValues(alpha: 0.3), + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), ), ), child: ConstrainedBox( @@ -113,7 +219,7 @@ class RepeaterIdChip extends StatelessWidget { Icon( Icons.cell_tower, size: 18, - color: Theme.of(dialogContext).colorScheme.primary, + color: Theme.of(context).colorScheme.primary, ), const SizedBox(width: 8), Text( @@ -122,21 +228,16 @@ class RepeaterIdChip extends StatelessWidget { fontSize: 15, fontWeight: FontWeight.w600, fontFamily: 'monospace', - color: Theme.of(dialogContext).colorScheme.onSurface, + color: Theme.of(context).colorScheme.onSurface, ), ), ], ), const SizedBox(height: 12), - Divider( - height: 1, color: Theme.of(dialogContext).dividerColor), + Divider(height: 1, color: Theme.of(context).dividerColor), const SizedBox(height: 12), - // Content (lazy-fetches repeaters if cache is empty) - _RepeaterPopupContent( - repeaterId: repeaterId, - fullHexId: fullHexId, - fromLatLng: fromLatLng, - ), + // Content + content, ], ), ), @@ -145,7 +246,7 @@ class RepeaterIdChip extends StatelessWidget { ); } - static Widget _buildRepeaterRow( + static Widget buildRepeaterRow( BuildContext context, Repeater repeater, { double? refLat, @@ -186,7 +287,7 @@ class RepeaterIdChip extends StatelessWidget { child: Row( children: [ // Colored badge — circle for short IDs, pill for longer - _buildHexBadge( + buildHexBadge( repeater.displayHexId(overrideHopBytes: regionHopBytesOverride), badgeColor), const SizedBox(width: 12), @@ -261,25 +362,8 @@ class RepeaterIdChip extends StatelessWidget { ); } - /// Caption-style italic message used inside the popup (e.g. "Unknown - /// repeater", "Repeater data not available"). Lifted out of the static - /// builder so `_RepeaterPopupContent` can reuse it. - static Widget _buildCaptionMessage(BuildContext context, String text) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Text( - text, - style: TextStyle( - fontStyle: FontStyle.italic, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 13, - ), - ), - ); - } - /// 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( @@ -304,179 +388,3 @@ class RepeaterIdChip extends StatelessWidget { ); } } - -/// Stateful popup body that lazily re-fetches the repeater list if the cache -/// is empty when the user opens the dialog (e.g. offline session, startup -/// race, or a transient fetch failure). See -/// `AppStateProvider.refetchRepeatersIfPossible` for the trigger. -class _RepeaterPopupContent extends StatefulWidget { - final String repeaterId; - final String? fullHexId; - final ({double lat, double lon})? fromLatLng; - - const _RepeaterPopupContent({ - required this.repeaterId, - this.fullHexId, - this.fromLatLng, - }); - - @override - State<_RepeaterPopupContent> createState() => _RepeaterPopupContentState(); -} - -class _RepeaterPopupContentState extends State<_RepeaterPopupContent> { - bool _loading = false; - bool _hasTriedRefetch = false; - - @override - void initState() { - super.initState(); - final appState = Provider.of(context, listen: false); - if (appState.repeaters.isEmpty && _hasIataAvailable(appState)) { - _kickOffRefetch(appState); - } - } - - bool _hasIataAvailable(AppStateProvider appState) { - if (appState.zoneCode?.isNotEmpty == true) return true; - final prefIata = appState.preferences.iataCode; - return prefIata != null && prefIata.isNotEmpty; - } - - void _kickOffRefetch(AppStateProvider appState) { - final iata = (appState.zoneCode?.isNotEmpty == true) - ? appState.zoneCode - : appState.preferences.iataCode; - debugLog( - '[MAP] Repeater popup opened with empty cache, refetching (iata=$iata)'); - setState(() { - _loading = true; - _hasTriedRefetch = true; - }); - appState.refetchRepeatersIfPossible().whenComplete(() { - if (!mounted) return; - setState(() => _loading = false); - }); - } - - @override - Widget build(BuildContext context) { - // Watch so the dialog rebuilds automatically when the fetch populates - // _repeaters (refetchRepeatersIfPossible calls notifyListeners on success). - final appState = context.watch(); - final repeaters = appState.repeaters; - - if (repeaters.isNotEmpty) { - return _buildMatches(context, appState, repeaters); - } - - if (_loading) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ), - const SizedBox(width: 10), - Text( - 'Loading repeaters…', - style: TextStyle( - fontStyle: FontStyle.italic, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 13, - ), - ), - ], - ), - ); - } - - // Not loading and no data. If we tried and got nothing, offer retry. - // Otherwise we have no IATA at all — show the terminal "not available". - if (_hasTriedRefetch) { - return InkWell( - onTap: () { - final appState = - Provider.of(context, listen: false); - _kickOffRefetch(appState); - }, - borderRadius: BorderRadius.circular(6), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.refresh, - size: 14, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 6), - Flexible( - child: Text( - "Couldn't load repeaters — tap to retry", - style: TextStyle( - fontStyle: FontStyle.italic, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 13, - ), - ), - ), - ], - ), - ), - ); - } - - return RepeaterIdChip._buildCaptionMessage( - context, 'Repeater data not available'); - } - - Widget _buildMatches( - BuildContext context, AppStateProvider appState, List repeaters) { - // 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 = widget.fullHexId != null && widget.fullHexId!.length >= 8 - ? widget.fullHexId!.substring(0, 8) - : widget.repeaterId; - final matches = repeaters - .where((r) => r.hexId.toLowerCase().startsWith(matchKey.toLowerCase())) - .toList(); - - if (matches.isEmpty) { - return RepeaterIdChip._buildCaptionMessage(context, 'Unknown repeater'); - } - - final position = appState.currentPosition; - final refLat = widget.fromLatLng?.lat ?? position?.latitude; - final refLon = widget.fromLatLng?.lon ?? position?.longitude; - - if (refLat != null && refLon != null) { - matches.sort((a, b) { - 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; - return Column( - mainAxisSize: MainAxisSize.min, - children: matches - .map((r) => RepeaterIdChip._buildRepeaterRow( - context, - r, - refLat: refLat, - refLon: refLon, - regionHopBytesOverride: regionOverride, - )) - .toList(), - ); - } -} diff --git a/lib/widgets/rx_path_chain.dart b/lib/widgets/rx_path_chain.dart new file mode 100644 index 0000000..9962d2c --- /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/pubspec.lock b/pubspec.lock index 5b88216..c63f0b2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -249,6 +249,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: @@ -430,22 +438,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.2.0" - flutter_map: - dependency: "direct main" - description: - name: flutter_map - sha256: "87cc8349b8fa5dccda5af50018c7374b6645334a0d680931c1fe11bce88fa5bb" - url: "https://pub.dev" - source: hosted - version: "6.2.1" - flutter_map_cancellable_tile_provider: - dependency: "direct main" - description: - name: flutter_map_cancellable_tile_provider - sha256: ae18dd59faf74f3eca1d28f83e59b47741bbff962e123bbebe9335c04d432f44 - url: "https://pub.dev" - source: hosted - version: "2.0.0" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -672,14 +664,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 +696,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 +751,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: @@ -960,14 +943,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 +959,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 +1188,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: @@ -1373,14 +1332,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 60b10a0..adba300 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.3.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 @@ -40,6 +38,7 @@ dependencies: font_awesome_flutter: ^10.7.0 fl_chart: ^0.69.0 web: ^1.1.0 + disk_space_plus: ^0.2.3 dev_dependencies: flutter_test: @@ -49,6 +48,18 @@ dev_dependencies: hive_generator: ^2.0.0 flutter_launcher_icons: ^0.14.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. +dependency_overrides: + maplibre_gl: + path: third_party/maplibre_gl + flutter_launcher_icons: android: true ios: true diff --git a/test/services/offline_session_service_test.dart b/test/services/offline_session_service_test.dart new file mode 100644 index 0000000..e7e7230 --- /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/transport/stream_frame_codec_test.dart b/test/services/transport/stream_frame_codec_test.dart new file mode 100644 index 0000000..ccabac5 --- /dev/null +++ b/test/services/transport/stream_frame_codec_test.dart @@ -0,0 +1,282 @@ +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/wire_tag_codec_test.dart b/test/services/wire_tag_codec_test.dart new file mode 100644 index 0000000..926a723 --- /dev/null +++ b/test/services/wire_tag_codec_test.dart @@ -0,0 +1,164 @@ +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 0000000..535ba33 --- /dev/null +++ b/test/utils/coverage_summary_test.dart @@ -0,0 +1,420 @@ +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 0000000..e09c789 --- /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 0000000..b1f6e87 --- /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/mvt_cells_test.dart b/test/utils/mvt_cells_test.dart new file mode 100644 index 0000000..cf293d1 --- /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/repeater_format_test.dart b/test/utils/repeater_format_test.dart new file mode 100644 index 0000000..891271a --- /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/third_party/maplibre_gl/CHANGELOG.md b/third_party/maplibre_gl/CHANGELOG.md new file mode 100644 index 0000000..806122f --- /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 0000000..daaf99d --- /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 0000000..7d323c9 --- /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 0000000..0caccc8 --- /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 0000000..114f4ec --- /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 0000000..48989cb --- /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 0000000..d64cd49 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 0000000..4a202fd --- /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 0000000..2ea73f0 --- /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 0000000..1d2dc33 --- /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 0000000..7e070ec --- /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 0000000..edb8d2e --- /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 0000000..2531e90 --- /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 0000000..766b865 --- /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 0000000..69684a2 --- /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 0000000..2dba967 --- /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 0000000..5a796e6 --- /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 0000000..98cc2da --- /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 0000000..e26dbfb --- /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 0000000..7fdda1d --- /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 0000000..bc1575c --- /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 0000000..edf891c --- /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 0000000..dadbd16 --- /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 0000000..c29fe17 --- /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 0000000..b0585a1 --- /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 0000000..de750dd --- /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 0000000..bfcd660 --- /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 0000000..a322e79 --- /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 0000000..810dbe8 --- /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 0000000..1a548ce --- /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 0000000..b7fb0c0 --- /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 0000000..6d621d3 --- /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 0000000..722be04 --- /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 0000000..e80a727 --- /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 0000000..a19be86 --- /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 0000000..78b7290 --- /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 0000000..d49a4af --- /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 0000000..a292c4f --- /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 0000000..4625b12 --- /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 0000000..802e479 --- /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 0000000..d97ad1d --- /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 0000000..b5f4746 --- /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 0000000..4a725d7 --- /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 0000000..350dd3f --- /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 0000000..1181366 --- /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 0000000..9e57cde --- /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 0000000..a4c5ec9 --- /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 0000000..f7e9304 --- /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 0000000..8ab4687 --- /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 0000000..026302c --- /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 0000000..f511b3c --- /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 0000000..2f9aed2 --- /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 0000000..d9bfe46 --- /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 0000000..258df0d --- /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 0000000..7e0e38d --- /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 0000000..87f4ba5 --- /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 0000000..c1f55cc --- /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 0000000..e5e70cc --- /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 0000000..22945ea --- /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 0000000..f5d99da --- /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/web/index.html b/web/index.html index e0c14e7..4af36e5 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; + + + +