diff --git a/CHANGELOG.md b/CHANGELOG.md index 1097c1ff..629af1fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,13 @@ ### main +* Add `MapboxMap.location.setExternalLocation`/`.clearExternalLocation`, letting apps drive the location puck from a location source other than the platform's default GPS-based provider (e.g. an indoor-positioning SDK). Resolves [#1085](https://github.com/mapbox/mapbox-maps-flutter/issues/1085). + ### 2.30.0 +* Introduce experimental `RasterLayer.rasterColorScale` property, resulting in more precise visualization with long-tailed raster-array data source. +* Promote `SymbolLayer.symbolZOffset` to stable. +* Fix `PointAnnotation.iconImageCrossFade` and `PointAnnotationOptions.iconImageCrossFade` missing their `@Deprecated` annotation, so the analyzer and IDEs showed no warning. Both fields are deprecated in favor of `PointAnnotationManager.iconImageCrossFade`. + ### 2.30.0-rc.1 * Add `LineLayer.lineBorderGradient` and `.lineBorderGradientExpression` to color a line's border along its length with a gradient driven by `line-progress`. Requires a GeoJSON source with `lineMetrics: true`. diff --git a/README.md b/README.md index 3a230ff6..255ecc25 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,19 @@ To use the 3D puck with model downloaded from Uri instead of the default 2D puck You can find more examples of customization in the sample [app](example/lib/location_example.dart). +### External location provider +To drive the puck from a location source other than the platform's default (GPS-based) provider — for example, an indoor-positioning SDK — call `MapboxMap.location.setExternalLocation`. This registers a native location-provider override on first call; until then, the map behaves exactly as it does with the default provider. + +```dart +mapboxMap.location.setExternalLocation( + latitude: 37.775, + longitude: -122.418, + heading: 90.0, + accuracy: 5.0); +``` + +Call `MapboxMap.location.clearExternalLocation()` to restore the default provider (e.g. falling back to GPS when leaving indoor coverage). + ## Markers and annotations Additional information is available in our [Flutter](https://docs.mapbox.com/flutter/maps/guides/markers-and-annotations/), [Android](https://docs.mapbox.com/android/maps/guides/annotations/), and [iOS](https://docs.mapbox.com/ios/maps/guides/annotations/) documentation. diff --git a/android/src/main/kotlin/com/mapbox/maps/mapbox_maps/ExternalLocationProvider.kt b/android/src/main/kotlin/com/mapbox/maps/mapbox_maps/ExternalLocationProvider.kt new file mode 100644 index 00000000..3f735274 --- /dev/null +++ b/android/src/main/kotlin/com/mapbox/maps/mapbox_maps/ExternalLocationProvider.kt @@ -0,0 +1,53 @@ +package com.mapbox.maps.mapbox_maps + +import com.mapbox.geojson.Point +import com.mapbox.maps.plugin.locationcomponent.LocationConsumer +import com.mapbox.maps.plugin.locationcomponent.LocationProvider +import java.util.concurrent.CopyOnWriteArrayList + +/** + * A [LocationProvider] whose data comes from outside Mapbox's own location + * stack. Dart pushes updates into it via [LocationComponentController]'s + * `setExternalLocation`/`clearExternalLocation` platform-channel handlers, + * instead of Mapbox reading the device's location itself through + * [com.mapbox.maps.plugin.locationcomponent.DefaultLocationProvider]. + * + * Registered with `mapView.location.setLocationProvider(...)` the first time + * `setExternalLocation` is called. Until then, the map behaves exactly as it + * does today (the default provider, unmodified). + * + * Note: unlike iOS's `Location`, this SDK's [LocationConsumer] has no floor + * concept at all — only [Point] and bearing/accuracy. Floor never flowed + * through Mapbox's location APIs on Android; callers that need floor-aware + * behavior (e.g. indoor-map puck opacity) handle it entirely separately, + * unaffected by this override. + */ +class ExternalLocationProvider : LocationProvider { + // Consumers come and go with puck visibility (same contract as any other + // LocationProvider) — a plain thread-safe list, since updates can arrive + // off the main thread depending on where the platform channel dispatches. + private val consumers = CopyOnWriteArrayList() + + override fun registerLocationConsumer(locationConsumer: LocationConsumer) { + consumers.add(locationConsumer) + } + + override fun unRegisterLocationConsumer(locationConsumer: LocationConsumer) { + consumers.remove(locationConsumer) + } + + /** Pushes a new position to every registered consumer. */ + fun updateLocation(point: Point) { + consumers.forEach { it.onLocationUpdated(point) } + } + + /** Pushes a new bearing/heading to every registered consumer. */ + fun updateBearing(bearing: Double) { + consumers.forEach { it.onBearingUpdated(bearing) } + } + + /** Pushes a new horizontal accuracy radius to every registered consumer. */ + fun updateAccuracyRadius(radiusMeters: Double) { + consumers.forEach { it.onHorizontalAccuracyRadiusUpdated(radiusMeters) } + } +} diff --git a/android/src/main/kotlin/com/mapbox/maps/mapbox_maps/LocationComponentController.kt b/android/src/main/kotlin/com/mapbox/maps/mapbox_maps/LocationComponentController.kt index ad76d39b..b219b7e9 100644 --- a/android/src/main/kotlin/com/mapbox/maps/mapbox_maps/LocationComponentController.kt +++ b/android/src/main/kotlin/com/mapbox/maps/mapbox_maps/LocationComponentController.kt @@ -1,6 +1,7 @@ package com.mapbox.maps.mapbox_maps import android.content.Context +import com.mapbox.geojson.Point import com.mapbox.maps.MapView import com.mapbox.maps.mapbox_maps.mapping.applyFromFLT import com.mapbox.maps.mapbox_maps.mapping.toFLT @@ -9,6 +10,9 @@ import com.mapbox.maps.plugin.LocationPuck2D import com.mapbox.maps.plugin.LocationPuck3D import com.mapbox.maps.plugin.locationcomponent.createDefault2DPuck import com.mapbox.maps.plugin.locationcomponent.location +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel class LocationComponentController( private val mapView: MapView, @@ -29,4 +33,95 @@ class LocationComponentController( (mapView.location.locationPuck as? LocationPuck2D)?.let { cachedPuck2D = it } (mapView.location.locationPuck as? LocationPuck3D)?.let { cachedPuck3D = it } } + + // Native location-provider override, so the puck can be driven by an + // externally-supplied combined GPS+indoor location provider instead of + // Mapbox's default provider. + private val externalLocationProvider = ExternalLocationProvider() + private var isOverrideActive = false + private var externalLocationChannel: MethodChannel? = null + + /** + * Sets up the plain [MethodChannel] for `setExternalLocation`/ + * `clearExternalLocation`. Deliberately not Pigeon-generated — Mapbox + * doesn't ship the Pigeon input specs for this plugin publicly, only the + * generated output, so this is a small hand-written channel kept isolated + * from the generated code to stay easy to rebase. Mirrors + * `LocationController.setUpExternalLocationChannel` on iOS. + */ + fun setUpExternalLocationChannel(messenger: BinaryMessenger, channelSuffix: String) { + val channel = MethodChannel( + messenger, + "plugins.flutter.io.mapbox_maps_flutter.externalLocation.$channelSuffix" + ) + channel.setMethodCallHandler { call, result -> handleExternalLocationCall(call, result) } + externalLocationChannel = channel + } + + private fun handleExternalLocationCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "setExternalLocation" -> setExternalLocation(call, result) + "clearExternalLocation" -> clearExternalLocation(result) + else -> result.notImplemented() + } + } + + private fun setExternalLocation(call: MethodCall, result: MethodChannel.Result) { + val latitude = call.argument("latitude") + val longitude = call.argument("longitude") + if (latitude == null || longitude == null) { + result.error("invalid_args", "setExternalLocation requires latitude and longitude", null) + return + } + + activateOverrideIfNeeded() + externalLocationProvider.updateLocation(Point.fromLngLat(longitude, latitude)) + + call.argument("heading")?.let { externalLocationProvider.updateBearing(it) } + call.argument("accuracy")?.let { externalLocationProvider.updateAccuracyRadius(it) } + // `floor` is intentionally not forwarded — see ExternalLocationProvider's + // doc comment: this SDK's LocationConsumer has no floor concept, unlike + // iOS's Location.floor. Callers that need floor-aware behavior (e.g. + // indoor-map puck opacity) handle it entirely separately from this + // override. + + result.success(null) + } + + /** + * Restores Mapbox's default location provider — "clear" means "go back to + * normal GPS." Intended usage: call this on a location-stream error, where + * presenting a stale synthetic position would be worse than falling back + * to GPS. + */ + private fun clearExternalLocation(result: MethodChannel.Result) { + if (isOverrideActive) { + isOverrideActive = false + mapView.location.setLocationProvider(defaultLocationProvider) + } + result.success(null) + } + + // Mapbox lazily creates its own DefaultLocationProvider the first time the + // location component is enabled with no provider set (see + // LocationComponentPluginImpl). Capture whatever is active *before* we ever + // swap in our own, so clearExternalLocation has something real to restore. + private val defaultLocationProvider by lazy { + mapView.location.getLocationProvider() + ?: com.mapbox.maps.plugin.locationcomponent.DefaultLocationProvider(context) + } + + /** + * Registers `externalLocationProvider` with Mapbox on first use only — + * until `setExternalLocation` is called at least once, the map behaves + * exactly as it does today (default provider, unmodified). + */ + private fun activateOverrideIfNeeded() { + if (isOverrideActive) return + // Force evaluation before swapping so it captures the real default, not + // our own override. + defaultLocationProvider + isOverrideActive = true + mapView.location.setLocationProvider(externalLocationProvider) + } } \ No newline at end of file diff --git a/android/src/main/kotlin/com/mapbox/maps/mapbox_maps/MapboxMapController.kt b/android/src/main/kotlin/com/mapbox/maps/mapbox_maps/MapboxMapController.kt index e585d272..c50beb75 100644 --- a/android/src/main/kotlin/com/mapbox/maps/mapbox_maps/MapboxMapController.kt +++ b/android/src/main/kotlin/com/mapbox/maps/mapbox_maps/MapboxMapController.kt @@ -205,6 +205,10 @@ class MapboxMapController( animationController = AnimationController(mapboxMap, context) annotationController = AnnotationController(mapView, messenger, this.channelSuffix) locationComponentController = LocationComponentController(mapView, context) + // Hand-written channel (not Pigeon-generated, see + // LocationComponentController.setUpExternalLocationChannel) for the + // native location-provider override. + locationComponentController.setUpExternalLocationChannel(messenger, this.channelSuffix) gestureController = GestureController(mapView, context) interactionsController = InteractionsController(mapboxMap, context) logoController = LogoController(mapView) diff --git a/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/ExternalLocationProvider.swift b/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/ExternalLocationProvider.swift new file mode 100644 index 00000000..6825c653 --- /dev/null +++ b/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/ExternalLocationProvider.swift @@ -0,0 +1,95 @@ +@_spi(Experimental) import MapboxMaps +import Foundation + +/// A `LocationProvider`/`HeadingProvider` whose data comes from outside Mapbox's +/// own location stack. Dart pushes updates into it via `LocationController`'s +/// `setExternalLocation`/`clearExternalLocation` platform-channel handlers +/// (see `LocationController.swift`) instead of Mapbox reading CoreLocation +/// itself through `AppleLocationProvider`. +/// +/// Registered with `mapView.location.override(provider:)` the first time +/// `setExternalLocation` is called. Until then, the map behaves exactly as it +/// does today (default `AppleLocationProvider`, unmodified). +final class ExternalLocationProvider: NSObject { + // Held weakly, matching the contract documented on `MBXLocationProvider`/ + // `LocationProvider`: observers come and go with puck visibility, and we + // must not be the reason one leaks. + private final class WeakLocationObserverBox { + weak var observer: LocationObserver? + init(_ observer: LocationObserver) { self.observer = observer } + } + private final class WeakHeadingObserverBox { + weak var observer: HeadingObserver? + init(_ observer: HeadingObserver) { self.observer = observer } + } + + private var locationObservers: [WeakLocationObserverBox] = [] + private var headingObservers: [WeakHeadingObserverBox] = [] + private var lastLocation: Location? + private var lastHeading: Heading? + + /// Pushes a new location to every registered observer (called from + /// `LocationController`'s `setExternalLocation` channel handler). + func update(location: Location) { + lastLocation = location + pruneLocationObservers() + for box in locationObservers { + box.observer?.onLocationUpdateReceived(for: [location]) + } + } + + /// Pushes a new heading/bearing to every registered observer. Only + /// relevant while the puck's `puckBearing` is configured as `.heading` + /// (the default `LocationComponentSettings` — see Mapbox's own puck + /// configuration docs). + func update(heading: Heading) { + lastHeading = heading + pruneHeadingObservers() + for box in headingObservers { + box.observer?.onHeadingUpdate(heading) + } + } + + /// Drops cached state. Called when Dart clears the override, so a stale + /// location/heading doesn't linger if the override is later re-armed. + func clear() { + lastLocation = nil + lastHeading = nil + } + + private func pruneLocationObservers() { + locationObservers.removeAll { $0.observer == nil } + } + + private func pruneHeadingObservers() { + headingObservers.removeAll { $0.observer == nil } + } +} + +extension ExternalLocationProvider: LocationProvider { + func getLastObservedLocation() -> Location? { + lastLocation + } + + func addLocationObserver(for observer: LocationObserver) { + pruneLocationObservers() + locationObservers.append(WeakLocationObserverBox(observer)) + } + + func removeLocationObserver(for observer: LocationObserver) { + locationObservers.removeAll { $0.observer == nil || $0.observer === observer } + } +} + +extension ExternalLocationProvider: HeadingProvider { + var latestHeading: Heading? { lastHeading } + + func add(headingObserver: HeadingObserver) { + pruneHeadingObservers() + headingObservers.append(WeakHeadingObserverBox(headingObserver)) + } + + func remove(headingObserver: HeadingObserver) { + headingObservers.removeAll { $0.observer == nil || $0.observer === headingObserver } + } +} diff --git a/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/LocationController.swift b/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/LocationController.swift index 2404c9f6..b74d6a08 100644 --- a/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/LocationController.swift +++ b/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/LocationController.swift @@ -56,9 +56,116 @@ final class LocationController: _LocationComponentSettingsInterface { private let mapView: MapView + // Native location-provider override, so the puck can be driven by an + // externally-supplied combined GPS+indoor location provider instead of + // Mapbox's default `AppleLocationProvider`. + private let externalLocationProvider = ExternalLocationProvider() + private var isOverrideActive = false + private var externalLocationChannel: FlutterMethodChannel? + init(withMapView mapView: MapView) { self.mapView = mapView } + + /// Sets up the plain `FlutterMethodChannel` for `setExternalLocation`/ + /// `clearExternalLocation`. Deliberately not Pigeon-generated — Mapbox + /// doesn't ship the Pigeon input specs for this plugin publicly, only the + /// generated output, so this is a small hand-written channel kept + /// isolated from the generated code to stay easy to rebase. + func setUpExternalLocationChannel(binaryMessenger: FlutterBinaryMessenger, channelSuffix: String) { + let channel = FlutterMethodChannel( + name: "plugins.flutter.io.mapbox_maps_flutter.externalLocation.\(channelSuffix)", + binaryMessenger: binaryMessenger) + channel.setMethodCallHandler { [weak self] call, result in + self?.handleExternalLocationCall(call, result: result) + } + externalLocationChannel = channel + } + + private func handleExternalLocationCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "setExternalLocation": + setExternalLocation(arguments: call.arguments, result: result) + case "clearExternalLocation": + clearExternalLocation(result: result) + default: + result(FlutterMethodNotImplemented) + } + } + + private func setExternalLocation(arguments: Any?, result: @escaping FlutterResult) { + guard let args = arguments as? [String: Any], + let latitude = args["latitude"] as? Double, + let longitude = args["longitude"] as? Double else { + result(FlutterError( + code: "invalid_args", + message: "setExternalLocation requires latitude and longitude", + details: nil)) + return + } + + let timestampMs = args["timestamp"] as? Double + // `Location`'s `timestamp` is milliseconds since epoch as `UInt64`, + // not a `Date` — see MapboxCommon's `MBXLocation`. + let timestampMillis = UInt64(timestampMs ?? (Date().timeIntervalSince1970 * 1000)) + let accuracy = args["accuracy"] as? Double + let heading = args["heading"] as? Double + let headingAccuracy = args["headingAccuracy"] as? Double + let floor = args["floor"] as? Int + // Used below to build the `Heading` update, which takes a `Date` + // (unlike `Location`, which takes raw millis). + let timestampDate = Date(timeIntervalSince1970: Double(timestampMillis) / 1000) + + let location = Location( + __latitude: latitude, + longitude: longitude, + timestamp: timestampMillis, + monotonicTimestamp: nil, + altitude: nil, + horizontalAccuracy: accuracy.map(NSNumber.init(value:)), + verticalAccuracy: nil, + speed: nil, + speedAccuracy: nil, + bearing: heading.map(NSNumber.init(value:)), + bearingAccuracy: headingAccuracy.map(NSNumber.init(value:)), + floor: floor.map(NSNumber.init(value:)), + source: "external-override", + extra: nil) + + activateOverrideIfNeeded() + externalLocationProvider.update(location: location) + + if let heading { + externalLocationProvider.update(heading: Heading( + direction: heading, + accuracy: headingAccuracy ?? 0, + timestamp: timestampDate)) + } + + result(nil) + } + + /// Restores Mapbox's default `AppleLocationProvider` — "clear" means + /// "go back to normal GPS." Intended usage: call this on a + /// location-stream error, where presenting a stale synthetic position + /// would be worse than falling back to GPS. + private func clearExternalLocation(result: @escaping FlutterResult) { + externalLocationProvider.clear() + if isOverrideActive { + isOverrideActive = false + mapView.location.override(provider: AppleLocationProvider()) + } + result(nil) + } + + /// Registers `externalLocationProvider` with Mapbox on first use only — + /// until `setExternalLocation` is called at least once, the map behaves + /// exactly as it does today (default `AppleLocationProvider`). + private func activateOverrideIfNeeded() { + guard !isOverrideActive else { return } + isOverrideActive = true + mapView.location.override(provider: externalLocationProvider) + } } extension LocationOptions { diff --git a/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/MapboxMapController.swift b/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/MapboxMapController.swift index d5dfa210..17e80590 100644 --- a/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/MapboxMapController.swift +++ b/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/MapboxMapController.swift @@ -74,6 +74,10 @@ public final class MapboxMapController: NSObject, FlutterPlatformView { let locationController = LocationController(withMapView: mapView) _LocationComponentSettingsInterfaceSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: locationController, messageChannelSuffix: binaryMessenger.suffix) + // Hand-written channel (not Pigeon-generated, see + // LocationController.setUpExternalLocationChannel) for the native + // location-provider override. + locationController.setUpExternalLocationChannel(binaryMessenger: binaryMessenger.messenger, channelSuffix: binaryMessenger.suffix) gesturesController = GesturesController(withMapView: mapView) GesturesSettingsInterfaceSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: gesturesController, messageChannelSuffix: binaryMessenger.suffix) diff --git a/lib/src/location_settings.dart b/lib/src/location_settings.dart index a6f2dfa5..aefb9c50 100644 --- a/lib/src/location_settings.dart +++ b/lib/src/location_settings.dart @@ -4,7 +4,67 @@ part of mapbox_maps_flutter; class LocationSettings { final _LocationComponentSettingsInterface _api; - LocationSettings._(this._api); + // Native location-provider override channel. Plain MethodChannel, not + // Pigeon-generated: Mapbox doesn't ship the Pigeon input specs for this + // plugin publicly, only the generated output, so this is a small + // hand-written channel kept isolated from the generated code to stay easy + // to rebase. Names must match `LocationController.swift` + // (`setUpExternalLocationChannel`) and `LocationComponentController.kt` + // (`setUpExternalLocationChannel`) exactly. + final MethodChannel _externalLocationChannel; + + LocationSettings._(this._api, {required String messageChannelSuffix, BinaryMessenger? binaryMessenger}) + : _externalLocationChannel = MethodChannel( + 'plugins.flutter.io.mapbox_maps_flutter.externalLocation.$messageChannelSuffix', + const StandardMethodCodec(), + binaryMessenger, + ); + + /// Pushes an externally-sourced location into the native location-provider + /// override, replacing whatever Mapbox's default location provider (GPS) + /// would otherwise show. Registers the override on first call; the map + /// behaves exactly as it does today until this is called at least once. + /// + /// [timestamp] defaults to now if omitted. [floor] is only meaningful on + /// iOS (Mapbox's native `Location` type carries it; the Android + /// `LocationConsumer` API has no floor concept at all, so it's dropped on + /// that platform — callers needing floor-aware behavior on Android should + /// track it themselves alongside the location). + /// + /// Example: + /// ```dart + /// mapboxMap.location.setExternalLocation( + /// latitude: 37.775, + /// longitude: -122.418, + /// heading: 90.0, + /// accuracy: 5.0); + /// ``` + Future setExternalLocation({ + required double latitude, + required double longitude, + double? accuracy, + double? heading, + double? headingAccuracy, + int? floor, + DateTime? timestamp, + }) { + return _externalLocationChannel.invokeMethod('setExternalLocation', { + 'latitude': latitude, + 'longitude': longitude, + if (accuracy != null) 'accuracy': accuracy, + if (heading != null) 'heading': heading, + if (headingAccuracy != null) 'headingAccuracy': headingAccuracy, + if (floor != null) 'floor': floor, + 'timestamp': + (timestamp ?? DateTime.now()).toUtc().millisecondsSinceEpoch.toDouble(), + }); + } + + /// Clears the override and restores Mapbox's default location provider + /// (i.e. back to normal GPS). + Future clearExternalLocation() { + return _externalLocationChannel.invokeMethod('clearExternalLocation'); + } /// Returns the currently applied settings, populated with default /// values for any fields not explicitly modified via [updateSettings]. diff --git a/lib/src/mapbox_map.dart b/lib/src/mapbox_map.dart index b7b53bce..5b536e9a 100644 --- a/lib/src/mapbox_map.dart +++ b/lib/src/mapbox_map.dart @@ -171,7 +171,9 @@ class MapboxMap extends ChangeNotifier { late final LocationSettings location = LocationSettings._( _LocationComponentSettingsInterface( binaryMessenger: _mapboxMapsPlatform.binaryMessenger, - messageChannelSuffix: _mapboxMapsPlatform.channelSuffix.toString())); + messageChannelSuffix: _mapboxMapsPlatform.channelSuffix.toString()), + binaryMessenger: _mapboxMapsPlatform.binaryMessenger, + messageChannelSuffix: _mapboxMapsPlatform.channelSuffix.toString()); late final _CameraManager _cameraManager = _CameraManager( binaryMessenger: _mapboxMapsPlatform.binaryMessenger, diff --git a/test/external_location_test.dart b/test/external_location_test.dart new file mode 100644 index 00000000..810f796e --- /dev/null +++ b/test/external_location_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart'; + +// Covers the hand-written (non-Pigeon) platform channel added for the +// native location-provider override. Mirrors the pattern in +// http_service_test.dart for a plain MethodChannel, going through the public +// MapboxMap.fromNativeController factory since LocationSettings itself has +// no public constructor. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channelSuffix = 0; + final channel = MethodChannel( + 'plugins.flutter.io.mapbox_maps_flutter.externalLocation.$channelSuffix', + ); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + late LocationSettings location; + late List log; + + setUp(() { + log = []; + location = MapboxMap.fromNativeController(channelSuffix).location; + messenger.setMockMethodCallHandler(channel, (call) async { + log.add(call); + return null; + }); + }); + + tearDown(() { + messenger.setMockMethodCallHandler(channel, null); + }); + + test('setExternalLocation forwards required fields', () async { + await location.setExternalLocation(latitude: 1.5, longitude: 2.5); + + expect(log, hasLength(1)); + expect(log.single.method, 'setExternalLocation'); + final args = log.single.arguments as Map; + expect(args['latitude'], 1.5); + expect(args['longitude'], 2.5); + expect(args['timestamp'], isA()); + expect(args.containsKey('accuracy'), isFalse); + expect(args.containsKey('heading'), isFalse); + expect(args.containsKey('headingAccuracy'), isFalse); + expect(args.containsKey('floor'), isFalse); + }); + + test('setExternalLocation forwards all optional fields when provided', + () async { + final timestamp = DateTime.utc(2026, 1, 1, 12); + await location.setExternalLocation( + latitude: 1.5, + longitude: 2.5, + accuracy: 5.0, + heading: 90.0, + headingAccuracy: 3.0, + floor: 2, + timestamp: timestamp, + ); + + final args = log.single.arguments as Map; + expect(args['accuracy'], 5.0); + expect(args['heading'], 90.0); + expect(args['headingAccuracy'], 3.0); + expect(args['floor'], 2); + expect(args['timestamp'], timestamp.millisecondsSinceEpoch.toDouble()); + }); + + test('clearExternalLocation calls through with no arguments', () async { + await location.clearExternalLocation(); + + expect(log, hasLength(1)); + expect(log.single.method, 'clearExternalLocation'); + expect(log.single.arguments, isNull); + }); +}