diff --git a/.watchmanconfig b/.watchmanconfig index 9e26dfe..0967ef4 100644 --- a/.watchmanconfig +++ b/.watchmanconfig @@ -1 +1 @@ -{} \ No newline at end of file +{} diff --git a/.yarnrc.yml b/.yarnrc.yml index 13215d6..5badb2e 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -3,8 +3,8 @@ nmHoistingLimits: workspaces plugins: - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs - spec: "@yarnpkg/plugin-interactive-tools" + spec: '@yarnpkg/plugin-interactive-tools' - path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs - spec: "@yarnpkg/plugin-workspace-tools" + spec: '@yarnpkg/plugin-workspace-tools' yarnPath: .yarn/releases/yarn-3.6.1.cjs diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 45d257b..8b4fcfd 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,4 +1,3 @@ - # Contributor Covenant Code of Conduct ## Our Pledge @@ -18,23 +17,23 @@ diverse, inclusive, and healthy community. Examples of behavior that contributes to a positive environment for our community include: -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience -* Focusing on what is best not just for us as individuals, but for the overall +- Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: -* The use of sexualized language or imagery, and sexual attention or advances of +- The use of sexualized language or imagery, and sexual attention or advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email address, +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a +- Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities diff --git a/android/src/main/java/com/mapboxnavigation/LRUCache.kt b/android/src/main/java/com/mapboxnavigation/LRUCache.kt new file mode 100644 index 0000000..217e707 --- /dev/null +++ b/android/src/main/java/com/mapboxnavigation/LRUCache.kt @@ -0,0 +1,80 @@ +class LRUCache(private val capacity: Int) { + + private inner class Node( + val key: K, + var value: V, + var prev: Node? = null, + var next: Node? = null + ) + + private val map = HashMap() + private var head: Node? = null // Most recently used + private var tail: Node? = null // Least recently used + + init { + require(capacity > 0) { "LRUCache capacity must be greater than 0" } + } + + @Synchronized + fun get(key: K): V? { + val node = map[key] ?: return null + moveToHead(node) + return node.value + } + + @Synchronized + fun put(key: K, value: V) { + val node = map[key] + if (node != null) { + node.value = value + moveToHead(node) + } else { + val newNode = Node(key, value) + map[key] = newNode + addToHead(newNode) + + if (map.size > capacity) { + removeTail()?.let { removed -> map.remove(removed.key) } + } + } + } + + // ==== Private helpers ==== + private fun moveToHead(node: Node) { + removeNode(node) + addToHead(node) + } + + private fun addToHead(node: Node) { + node.prev = null + node.next = head + head?.prev = node + head = node + if (tail == null) { + tail = node + } + } + + private fun removeNode(node: Node) { + val prev = node.prev + val next = node.next + + if (prev != null) { + prev.next = next + } else { + head = next + } + + if (next != null) { + next.prev = prev + } else { + tail = prev + } + } + + private fun removeTail(): Node? { + val node = tail ?: return null + removeNode(node) + return node + } +} diff --git a/android/src/main/java/com/mapboxnavigation/MapboxNavigationPackage.kt b/android/src/main/java/com/mapboxnavigation/MapboxNavigationPackage.kt index 82fdf46..bc9af4a 100644 --- a/android/src/main/java/com/mapboxnavigation/MapboxNavigationPackage.kt +++ b/android/src/main/java/com/mapboxnavigation/MapboxNavigationPackage.kt @@ -14,6 +14,6 @@ class MapboxNavigationViewPackage : ReactPackage { } override fun createNativeModules(reactContext: ReactApplicationContext): List { - return emptyList() + return listOf(ParticipantsManager(reactContext)) } } diff --git a/android/src/main/java/com/mapboxnavigation/MapboxNavigationView.kt b/android/src/main/java/com/mapboxnavigation/MapboxNavigationView.kt index fd6e6b8..d423ea2 100644 --- a/android/src/main/java/com/mapboxnavigation/MapboxNavigationView.kt +++ b/android/src/main/java/com/mapboxnavigation/MapboxNavigationView.kt @@ -1,17 +1,31 @@ package com.mapboxnavigation +import LRUCache +import android.animation.TypeEvaluator +import android.animation.ValueAnimator import android.annotation.SuppressLint +import android.content.Context import android.content.res.Configuration import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.BitmapShader +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Shader +import android.net.Uri import android.util.Log import android.view.LayoutInflater import android.view.View +import android.view.animation.LinearInterpolator import android.widget.FrameLayout import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.UiThreadUtil.runOnUiThread import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.events.RCTEventEmitter import com.mapbox.api.directions.v5.DirectionsCriteria -import com.mapbox.api.directions.v5.models.DirectionsWaypoint import com.mapbox.api.directions.v5.models.RouteOptions import com.mapbox.bindgen.Expected import com.mapbox.common.location.Location @@ -19,8 +33,14 @@ import com.mapbox.geojson.Point import com.mapbox.maps.CameraOptions import com.mapbox.maps.EdgeInsets import com.mapbox.maps.ImageHolder +import com.mapbox.maps.extension.style.layers.properties.generated.IconPitchAlignment import com.mapbox.maps.plugin.LocationPuck2D import com.mapbox.maps.plugin.animation.camera +import com.mapbox.maps.plugin.annotation.annotations +import com.mapbox.maps.plugin.annotation.generated.PointAnnotation +import com.mapbox.maps.plugin.annotation.generated.PointAnnotationManager +import com.mapbox.maps.plugin.annotation.generated.PointAnnotationOptions +import com.mapbox.maps.plugin.annotation.generated.createPointAnnotationManager import com.mapbox.maps.plugin.locationcomponent.location import com.mapbox.navigation.base.TimeFormat import com.mapbox.navigation.base.extensions.applyDefaultNavigationOptions @@ -80,10 +100,17 @@ import com.mapbox.navigation.voice.model.SpeechError import com.mapbox.navigation.voice.model.SpeechValue import com.mapbox.navigation.voice.model.SpeechVolume import com.mapboxnavigation.databinding.NavigationViewBinding +import java.io.File +import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URL import java.util.Locale +import kotlin.concurrent.thread +import android.graphics.* @SuppressLint("ViewConstructor") -class MapboxNavigationView(private val context: ThemedReactContext): FrameLayout(context.baseContext) { +class MapboxNavigationView(private val context: ThemedReactContext) : + FrameLayout(context.baseContext), ParticipantsManager.ParticipantsDelegate { private companion object { private const val BUTTON_ANIMATION_DURATION = 1500L } @@ -96,26 +123,28 @@ class MapboxNavigationView(private val context: ThemedReactContext): FrameLayout private var distanceUnit: String = DirectionsCriteria.IMPERIAL private var locale = Locale.getDefault() private var travelMode: String = DirectionsCriteria.PROFILE_DRIVING + private val userAnnotations: MutableMap = mutableMapOf() + private val animators: MutableMap = mutableMapOf() + public var pointAnnotationManager: PointAnnotationManager? = null + val cacheManager = LRUCache(10000000) - /** - * Bindings to the example layout. - */ - private var binding: NavigationViewBinding = NavigationViewBinding.inflate(LayoutInflater.from(context), this, true) + /** Bindings to the example layout. */ + private var binding: NavigationViewBinding = + NavigationViewBinding.inflate(LayoutInflater.from(context), this, true) /** - * Produces the camera frames based on the location and routing data for the [navigationCamera] to execute. + * Produces the camera frames based on the location and routing data for the [navigationCamera] to + * execute. */ private var viewportDataSource = MapboxNavigationViewportDataSource(binding.mapView.mapboxMap) /** * Used to execute camera transitions based on the data generated by the [viewportDataSource]. - * This includes transitions from route overview to route following and continuously updating the camera as the location changes. + * This includes transitions from route overview to route following and continuously updating the + * camera as the location changes. */ - private var navigationCamera = NavigationCamera( - binding.mapView.mapboxMap, - binding.mapView.camera, - viewportDataSource - ) + private var navigationCamera = + NavigationCamera(binding.mapView.mapboxMap, binding.mapView.camera, viewportDataSource) /** * Mapbox Navigation entry point. There should only be one instance of this object for the app. @@ -129,36 +158,16 @@ class MapboxNavigationView(private val context: ThemedReactContext): FrameLayout */ private val pixelDensity = Resources.getSystem().displayMetrics.density private val overviewPadding: EdgeInsets by lazy { - EdgeInsets( - 140.0 * pixelDensity, - 40.0 * pixelDensity, - 120.0 * pixelDensity, - 40.0 * pixelDensity - ) + EdgeInsets(140.0 * pixelDensity, 40.0 * pixelDensity, 120.0 * pixelDensity, 40.0 * pixelDensity) } private val landscapeOverviewPadding: EdgeInsets by lazy { - EdgeInsets( - 30.0 * pixelDensity, - 380.0 * pixelDensity, - 110.0 * pixelDensity, - 20.0 * pixelDensity - ) + EdgeInsets(30.0 * pixelDensity, 380.0 * pixelDensity, 110.0 * pixelDensity, 20.0 * pixelDensity) } private val followingPadding: EdgeInsets by lazy { - EdgeInsets( - 180.0 * pixelDensity, - 40.0 * pixelDensity, - 150.0 * pixelDensity, - 40.0 * pixelDensity - ) + EdgeInsets(180.0 * pixelDensity, 40.0 * pixelDensity, 150.0 * pixelDensity, 40.0 * pixelDensity) } private val landscapeFollowingPadding: EdgeInsets by lazy { - EdgeInsets( - 30.0 * pixelDensity, - 380.0 * pixelDensity, - 110.0 * pixelDensity, - 40.0 * pixelDensity - ) + EdgeInsets(30.0 * pixelDensity, 380.0 * pixelDensity, 110.0 * pixelDensity, 40.0 * pixelDensity) } /** @@ -168,12 +177,14 @@ class MapboxNavigationView(private val context: ThemedReactContext): FrameLayout private lateinit var maneuverApi: MapboxManeuverApi /** - * Generates updates for the [MapboxTripProgressView] that include remaining time and distance to the destination. + * Generates updates for the [MapboxTripProgressView] that include remaining time and distance to + * the destination. */ private lateinit var tripProgressApi: MapboxTripProgressApi /** - * Stores and updates the state of whether the voice instructions should be played as they come or muted. + * Stores and updates the state of whether the voice instructions should be played as they come or + * muted. */ private var isVoiceInstructionsMuted = false set(value) { @@ -188,42 +199,36 @@ class MapboxNavigationView(private val context: ThemedReactContext): FrameLayout } /** - * Extracts message that should be communicated to the driver about the upcoming maneuver. - * When possible, downloads a synthesized audio file that can be played back to the driver. + * Extracts message that should be communicated to the driver about the upcoming maneuver. When + * possible, downloads a synthesized audio file that can be played back to the driver. */ private lateinit var speechApi: MapboxSpeechApi /** - * Plays the synthesized audio files with upcoming maneuver instructions - * or uses an on-device Text-To-Speech engine to communicate the message to the driver. - * NOTE: do not use lazy initialization for this class since it takes some time to initialize - * the system services required for on-device speech synthesis. With lazy initialization - * there is a high risk that said services will not be available when the first instruction - * has to be played. [MapboxVoiceInstructionsPlayer] should be instantiated in - * `Activity#onCreate`. + * Plays the synthesized audio files with upcoming maneuver instructions or uses an on-device + * Text-To-Speech engine to communicate the message to the driver. NOTE: do not use lazy + * initialization for this class since it takes some time to initialize the system services + * required for on-device speech synthesis. With lazy initialization there is a high risk that + * said services will not be available when the first instruction has to be played. + * [MapboxVoiceInstructionsPlayer] should be instantiated in `Activity#onCreate`. */ private var voiceInstructionsPlayer: MapboxVoiceInstructionsPlayer? = null - /** - * Observes when a new voice instruction should be played. - */ + /** Observes when a new voice instruction should be played. */ private val voiceInstructionsObserver = VoiceInstructionsObserver { voiceInstructions -> speechApi.generate(voiceInstructions, speechCallback) } /** - * Based on whether the synthesized audio file is available, the callback plays the file - * or uses the fall back which is played back using the on-device Text-To-Speech engine. + * Based on whether the synthesized audio file is available, the callback plays the file or uses + * the fall back which is played back using the on-device Text-To-Speech engine. */ private val speechCallback = MapboxNavigationConsumer> { expected -> expected.fold( { error -> // play the instruction via fallback text-to-speech engine - voiceInstructionsPlayer?.play( - error.fallback, - voiceInstructionsPlayerCallback - ) + voiceInstructionsPlayer?.play(error.fallback, voiceInstructionsPlayerCallback) }, { value -> // play the sound file from the external generator @@ -236,7 +241,8 @@ class MapboxNavigationView(private val context: ThemedReactContext): FrameLayout } /** - * When a synthesized audio file was downloaded, this callback cleans up the disk after it was played. + * When a synthesized audio file was downloaded, this callback cleans up the disk after it was + * played. */ private val voiceInstructionsPlayerCallback = MapboxNavigationConsumer { value -> @@ -245,25 +251,26 @@ class MapboxNavigationView(private val context: ThemedReactContext): FrameLayout } /** - * [NavigationLocationProvider] is a utility class that helps to provide location updates generated by the Navigation SDK - * to the Maps SDK in order to update the user location indicator on the map. + * [NavigationLocationProvider] is a utility class that helps to provide location updates + * generated by the Navigation SDK to the Maps SDK in order to update the user location indicator + * on the map. */ private val navigationLocationProvider = NavigationLocationProvider() /** - * RouteLine: Additional route line options are available through the - * [MapboxRouteLineViewOptions] and [MapboxRouteLineApiOptions]. - * Notice here the [MapboxRouteLineViewOptions.routeLineBelowLayerId] option. The map is made up of layers. In this - * case the route line will be placed below the "road-label" layer which is a good default + * RouteLine: Additional route line options are available through the [MapboxRouteLineViewOptions] + * and [MapboxRouteLineApiOptions]. Notice here the + * [MapboxRouteLineViewOptions.routeLineBelowLayerId] option. The map is made up of layers. In + * this case the route line will be placed below the "road-label" layer which is a good default * for the most common Mapbox navigation related maps. You should consider if this should be * changed for your use case especially if you are using a custom map style. */ private val routeLineViewOptions: MapboxRouteLineViewOptions by lazy { MapboxRouteLineViewOptions.Builder(context) /** - * Route line related colors can be customized via the [RouteLineColorResources]. If using the - * default colors the [RouteLineColorResources] does not need to be set as seen here, the - * defaults will be used internally by the builder. + * Route line related colors can be customized via the [RouteLineColorResources]. If + * using the default colors the [RouteLineColorResources] does not need to be set as + * seen here, the defaults will be used internally by the builder. */ .routeLineColorResources(RouteLineColorResources.Builder().build()) .routeLineBelowLayerId("road-label-navigation") @@ -271,51 +278,40 @@ class MapboxNavigationView(private val context: ThemedReactContext): FrameLayout } private val routeLineApiOptions: MapboxRouteLineApiOptions by lazy { - MapboxRouteLineApiOptions.Builder() - .build() + MapboxRouteLineApiOptions.Builder().build() } /** - * RouteLine: This class is responsible for rendering route line related mutations generated - * by the [routeLineApi] + * RouteLine: This class is responsible for rendering route line related mutations generated by + * the [routeLineApi] */ - private val routeLineView by lazy { - MapboxRouteLineView(routeLineViewOptions) - } - + private val routeLineView by lazy { MapboxRouteLineView(routeLineViewOptions) } /** * RouteLine: This class is responsible for generating route line related data which must be * rendered by the [routeLineView] in order to visualize the route line on the map. */ - private val routeLineApi: MapboxRouteLineApi by lazy { - MapboxRouteLineApi(routeLineApiOptions) - } + private val routeLineApi: MapboxRouteLineApi by lazy { MapboxRouteLineApi(routeLineApiOptions) } /** - * RouteArrow: This class is responsible for generating data related to maneuver arrows. The - * data generated must be rendered by the [routeArrowView] in order to apply mutations to - * the map. + * RouteArrow: This class is responsible for generating data related to maneuver arrows. The data + * generated must be rendered by the [routeArrowView] in order to apply mutations to the map. */ - private val routeArrowApi: MapboxRouteArrowApi by lazy { - MapboxRouteArrowApi() - } + private val routeArrowApi: MapboxRouteArrowApi by lazy { MapboxRouteArrowApi() } /** - * RouteArrow: Customization of the maneuver arrow(s) can be done using the - * [RouteArrowOptions]. Here the above layer ID is used to determine where in the map layer - * stack the arrows appear. Above the layer of the route traffic line is being used here. Your - * use case may necessitate adjusting this to a different layer position. + * RouteArrow: Customization of the maneuver arrow(s) can be done using the [RouteArrowOptions]. + * Here the above layer ID is used to determine where in the map layer stack the arrows appear. + * Above the layer of the route traffic line is being used here. Your use case may necessitate + * adjusting this to a different layer position. */ private val routeArrowOptions by lazy { - RouteArrowOptions.Builder(context) - .withAboveLayerId(TOP_LEVEL_ROUTE_LINE_LAYER_ID) - .build() + RouteArrowOptions.Builder(context).withAboveLayerId(TOP_LEVEL_ROUTE_LINE_LAYER_ID).build() } /** - * RouteArrow: This class is responsible for rendering the arrow related mutations generated - * by the [routeArrowApi] + * RouteArrow: This class is responsible for rendering the arrow related mutations generated by + * the [routeArrowApi] */ private val routeArrowView: MapboxRouteArrowView by lazy { MapboxRouteArrowView(routeArrowOptions) @@ -324,53 +320,52 @@ class MapboxNavigationView(private val context: ThemedReactContext): FrameLayout /** * Gets notified with location updates. * - * Exposes raw updates coming directly from the location services - * and the updates enhanced by the Navigation SDK (cleaned up and matched to the road). + * Exposes raw updates coming directly from the location services and the updates enhanced by the + * Navigation SDK (cleaned up and matched to the road). */ - private val locationObserver = object : LocationObserver { - var firstLocationUpdateReceived = false + private val locationObserver = + object : LocationObserver { + var firstLocationUpdateReceived = false - override fun onNewRawLocation(rawLocation: Location) { - // not handled - } + override fun onNewRawLocation(rawLocation: Location) { + // not handled + } - override fun onNewLocationMatcherResult(locationMatcherResult: LocationMatcherResult) { - val enhancedLocation = locationMatcherResult.enhancedLocation - // update location puck's position on the map - navigationLocationProvider.changePosition( - location = enhancedLocation, - keyPoints = locationMatcherResult.keyPoints, - ) + override fun onNewLocationMatcherResult(locationMatcherResult: LocationMatcherResult) { + val enhancedLocation = locationMatcherResult.enhancedLocation + // update location puck's position on the map + navigationLocationProvider.changePosition( + location = enhancedLocation, + keyPoints = locationMatcherResult.keyPoints, + ) - // update camera position to account for new location - viewportDataSource.onLocationChanged(enhancedLocation) - viewportDataSource.evaluate() + // update camera position to account for new location + viewportDataSource.onLocationChanged(enhancedLocation) + viewportDataSource.evaluate() + + // if this is the first location update the activity has received, + // it's best to immediately move the camera to the current user location + if (!firstLocationUpdateReceived) { + firstLocationUpdateReceived = true + navigationCamera.requestNavigationCameraToOverview( + stateTransitionOptions = + NavigationCameraTransitionOptions.Builder() + .maxDuration(0) // instant transition + .build() + ) + } - // if this is the first location update the activity has received, - // it's best to immediately move the camera to the current user location - if (!firstLocationUpdateReceived) { - firstLocationUpdateReceived = true - navigationCamera.requestNavigationCameraToOverview( - stateTransitionOptions = NavigationCameraTransitionOptions.Builder() - .maxDuration(0) // instant transition - .build() - ) + val event = Arguments.createMap() + event.putDouble("longitude", enhancedLocation.longitude) + event.putDouble("latitude", enhancedLocation.latitude) + event.putDouble("heading", enhancedLocation.bearing ?: 0.0) + event.putDouble("accuracy", enhancedLocation.horizontalAccuracy ?: 0.0) + context.getJSModule(RCTEventEmitter::class.java) + .receiveEvent(id, "onLocationChange", event) } - - val event = Arguments.createMap() - event.putDouble("longitude", enhancedLocation.longitude) - event.putDouble("latitude", enhancedLocation.latitude) - event.putDouble("heading", enhancedLocation.bearing ?: 0.0) - event.putDouble("accuracy", enhancedLocation.horizontalAccuracy ?: 0.0) - context - .getJSModule(RCTEventEmitter::class.java) - .receiveEvent(id, "onLocationChange", event) } - } - /** - * Gets notified with progress along the currently active route. - */ + /** Gets notified with progress along the currently active route. */ private val routeProgressObserver = RouteProgressObserver { routeProgress -> // update the camera position to account for the progressed fragment of the route if (routeProgress.fractionTraveled.toDouble() != 0.0) { @@ -388,28 +383,27 @@ class MapboxNavigationView(private val context: ThemedReactContext): FrameLayout // update top banner with maneuver instructions val maneuvers = maneuverApi.getManeuvers(routeProgress) maneuvers.fold( - { error -> - Log.w("Maneuvers error:", error.throwable) - }, + { error -> Log.w("Maneuvers error:", error.throwable) }, { - val maneuverViewOptions = ManeuverViewOptions.Builder() - .primaryManeuverOptions( - ManeuverPrimaryOptions.Builder() - .textAppearance(R.style.PrimaryManeuverTextAppearance) - .build() - ) - .secondaryManeuverOptions( - ManeuverSecondaryOptions.Builder() - .textAppearance(R.style.ManeuverTextAppearance) - .build() - ) - .subManeuverOptions( - ManeuverSubOptions.Builder() - .textAppearance(R.style.ManeuverTextAppearance) - .build() - ) - .stepDistanceTextAppearance(R.style.StepDistanceRemainingAppearance) - .build() + val maneuverViewOptions = + ManeuverViewOptions.Builder() + .primaryManeuverOptions( + ManeuverPrimaryOptions.Builder() + .textAppearance(R.style.PrimaryManeuverTextAppearance) + .build() + ) + .secondaryManeuverOptions( + ManeuverSecondaryOptions.Builder() + .textAppearance(R.style.ManeuverTextAppearance) + .build() + ) + .subManeuverOptions( + ManeuverSubOptions.Builder() + .textAppearance(R.style.ManeuverTextAppearance) + .build() + ) + .stepDistanceTextAppearance(R.style.StepDistanceRemainingAppearance) + .build() binding.maneuverView.visibility = View.VISIBLE binding.maneuverView.updateManeuverViewOptions(maneuverViewOptions) @@ -418,17 +412,14 @@ class MapboxNavigationView(private val context: ThemedReactContext): FrameLayout ) // update bottom trip progress summary - binding.tripProgressView.render( - tripProgressApi.getTripProgress(routeProgress) - ) + binding.tripProgressView.render(tripProgressApi.getTripProgress(routeProgress)) val event = Arguments.createMap() event.putDouble("distanceTraveled", routeProgress.distanceTraveled.toDouble()) event.putDouble("durationRemaining", routeProgress.durationRemaining) event.putDouble("fractionTraveled", routeProgress.fractionTraveled.toDouble()) event.putDouble("distanceRemaining", routeProgress.distanceRemaining.toDouble()) - context - .getJSModule(RCTEventEmitter::class.java) + context.getJSModule(RCTEventEmitter::class.java) .receiveEvent(id, "onRouteProgressChange", event) } @@ -437,18 +428,15 @@ class MapboxNavigationView(private val context: ThemedReactContext): FrameLayout * * A change can mean: * - routes get changed with [MapboxNavigation.setNavigationRoutes] - * - routes annotations get refreshed (for example, congestion annotation that indicate the live traffic along the route) + * - routes annotations get refreshed (for example, congestion annotation that indicate the live + * traffic along the route) * - driver got off route and a reroute was executed */ private val routesObserver = RoutesObserver { routeUpdateResult -> if (routeUpdateResult.navigationRoutes.isNotEmpty()) { // generate route geometries asynchronously and render them - routeLineApi.setNavigationRoutes( - routeUpdateResult.navigationRoutes - ) { value -> - binding.mapView.mapboxMap.style?.apply { - routeLineView.renderRouteDrawData(this, value) - } + routeLineApi.setNavigationRoutes(routeUpdateResult.navigationRoutes) { value -> + binding.mapView.mapboxMap.style?.apply { routeLineView.renderRouteDrawData(this, value) } } // update the camera position to account for the new route @@ -459,10 +447,7 @@ class MapboxNavigationView(private val context: ThemedReactContext): FrameLayout val style = binding.mapView.mapboxMap.style if (style != null) { routeLineApi.clearRouteLine { value -> - routeLineView.renderClearRouteLineValue( - style, - value - ) + routeLineView.renderClearRouteLineValue(style, value) } routeArrowView.render(style, routeArrowApi.clearArrows()) } @@ -479,14 +464,13 @@ class MapboxNavigationView(private val context: ThemedReactContext): FrameLayout private fun onCreate() { // initialize Mapbox Navigation - mapboxNavigation = if (MapboxNavigationProvider.isCreated()) { - MapboxNavigationProvider.retrieve() - } else { - MapboxNavigationProvider.create( - NavigationOptions.Builder(context) - .build() - ) - } + mapboxNavigation = + if (MapboxNavigationProvider.isCreated()) { + MapboxNavigationProvider.retrieve() + } else { + MapboxNavigationProvider.create(NavigationOptions.Builder(context).build()) + } + ParticipantsManager.shared?.delegate = this } @SuppressLint("MissingPermission") @@ -497,10 +481,7 @@ class MapboxNavigationView(private val context: ThemedReactContext): FrameLayout } // Recenter Camera - val initialCameraOptions = CameraOptions.Builder() - .zoom(14.0) - .center(origin) - .build() + val initialCameraOptions = CameraOptions.Builder().zoom(14.0).center(origin).build() binding.mapView.mapboxMap.setCamera(initialCameraOptions) // Start Navigation @@ -514,8 +495,8 @@ class MapboxNavigationView(private val context: ThemedReactContext): FrameLayout navigationCamera.registerNavigationCameraStateChangeObserver { navigationCameraState -> // shows/hide the recenter button depending on the camera state when (navigationCameraState) { - NavigationCameraState.TRANSITION_TO_FOLLOWING, - NavigationCameraState.FOLLOWING -> binding.recenter.visibility = View.INVISIBLE + NavigationCameraState.TRANSITION_TO_FOLLOWING, NavigationCameraState.FOLLOWING -> + binding.recenter.visibility = View.INVISIBLE NavigationCameraState.TRANSITION_TO_OVERVIEW, NavigationCameraState.OVERVIEW, @@ -536,41 +517,32 @@ class MapboxNavigationView(private val context: ThemedReactContext): FrameLayout // make sure to use the same DistanceFormatterOptions across different features val unitType = if (distanceUnit == "imperial") UnitType.IMPERIAL else UnitType.METRIC - val distanceFormatterOptions = DistanceFormatterOptions.Builder(context) - .unitType(unitType) - .build() + val distanceFormatterOptions = + DistanceFormatterOptions.Builder(context).unitType(unitType).build() // initialize maneuver api that feeds the data to the top banner maneuver view - maneuverApi = MapboxManeuverApi( - MapboxDistanceFormatter(distanceFormatterOptions) - ) + maneuverApi = MapboxManeuverApi(MapboxDistanceFormatter(distanceFormatterOptions)) // initialize bottom progress view - tripProgressApi = MapboxTripProgressApi( - TripProgressUpdateFormatter.Builder(context) - .distanceRemainingFormatter( - DistanceRemainingFormatter(distanceFormatterOptions) - ) - .timeRemainingFormatter( - TimeRemainingFormatter(context) - ) - .percentRouteTraveledFormatter( - PercentDistanceTraveledFormatter() - ) - .estimatedTimeToArrivalFormatter( - EstimatedTimeToArrivalFormatter(context, TimeFormat.NONE_SPECIFIED) - ) - .build() - ) + tripProgressApi = + MapboxTripProgressApi( + TripProgressUpdateFormatter.Builder(context) + .distanceRemainingFormatter( + DistanceRemainingFormatter(distanceFormatterOptions) + ) + .timeRemainingFormatter(TimeRemainingFormatter(context)) + .percentRouteTraveledFormatter(PercentDistanceTraveledFormatter()) + .estimatedTimeToArrivalFormatter( + EstimatedTimeToArrivalFormatter( + context, + TimeFormat.NONE_SPECIFIED + ) + ) + .build() + ) // initialize voice instructions api and the voice instruction player - speechApi = MapboxSpeechApi( - context, - locale.language - ) - voiceInstructionsPlayer = MapboxVoiceInstructionsPlayer( - context, - locale.language - ) + speechApi = MapboxSpeechApi(context, locale.language) + voiceInstructionsPlayer = MapboxVoiceInstructionsPlayer(context, locale.language) // load map style binding.mapView.mapboxMap.loadStyle(NavigationStyles.NAVIGATION_DAY_STYLE) { @@ -582,9 +554,7 @@ class MapboxNavigationView(private val context: ThemedReactContext): FrameLayout binding.stop.setOnClickListener { val event = Arguments.createMap() event.putString("message", "Navigation Cancel") - context - .getJSModule(RCTEventEmitter::class.java) - .receiveEvent(id, "onCancelNavigation", event) + context.getJSModule(RCTEventEmitter::class.java).receiveEvent(id, "onCancelNavigation", event) } binding.recenter.setOnClickListener { @@ -623,11 +593,19 @@ class MapboxNavigationView(private val context: ThemedReactContext): FrameLayout // initialize location puck binding.mapView.location.apply { setLocationProvider(navigationLocationProvider) - this.locationPuck = LocationPuck2D( - bearingImage = ImageHolder.Companion.from( - com.mapbox.navigation.ui.maps.R.drawable.mapbox_navigation_puck_icon + this.locationPuck = + LocationPuck2D( + bearingImage = + ImageHolder.from( + com.mapbox + .navigation + .ui + .maps + .R + .drawable + .mapbox_navigation_puck_icon + ) ) - ) puckBearingEnabled = true enabled = true } @@ -635,20 +613,21 @@ class MapboxNavigationView(private val context: ThemedReactContext): FrameLayout startRoute() } - private val arrivalObserver = object : ArrivalObserver { + private val arrivalObserver = + object : ArrivalObserver { - override fun onWaypointArrival(routeProgress: RouteProgress) { - onArrival(routeProgress) - } + override fun onWaypointArrival(routeProgress: RouteProgress) { + onArrival(routeProgress) + } - override fun onNextRouteLegStart(routeLegProgress: RouteLegProgress) { - // do something when the user starts a new leg - } + override fun onNextRouteLegStart(routeLegProgress: RouteLegProgress) { + // do something when the user starts a new leg + } - override fun onFinalDestinationArrival(routeProgress: RouteProgress) { - onArrival(routeProgress) + override fun onFinalDestinationArrival(routeProgress: RouteProgress) { + onArrival(routeProgress) + } } - } private fun onArrival(routeProgress: RouteProgress) { val leg = routeProgress.currentLegProgress @@ -657,9 +636,7 @@ class MapboxNavigationView(private val context: ThemedReactContext): FrameLayout event.putInt("index", leg.legIndex) event.putDouble("latitude", leg.legDestination?.location?.latitude() ?: 0.0) event.putDouble("longitude", leg.legDestination?.location?.longitude() ?: 0.0) - context - .getJSModule(RCTEventEmitter::class.java) - .receiveEvent(id, "onArrive", event) + context.getJSModule(RCTEventEmitter::class.java).receiveEvent(id, "onArrive", event) } } @@ -701,7 +678,10 @@ class MapboxNavigationView(private val context: ThemedReactContext): FrameLayout .profile(travelMode) .build(), object : NavigationRouterCallback { - override fun onCanceled(routeOptions: RouteOptions, @RouterOrigin routerOrigin: String) { + override fun onCanceled( + routeOptions: RouteOptions, + @RouterOrigin routerOrigin: String + ) { // no implementation } @@ -726,12 +706,12 @@ class MapboxNavigationView(private val context: ThemedReactContext): FrameLayout mapboxNavigation?.setNavigationRoutes(routes) // show UI elements - binding.soundButton.visibility = View.VISIBLE - binding.routeOverview.visibility = View.VISIBLE - binding.tripProgressCard.visibility = View.VISIBLE + binding.soundButton.visibility = VISIBLE + binding.routeOverview.visibility = VISIBLE + binding.tripProgressCard.visibility = VISIBLE // move the camera to overview when new route is available -// navigationCamera.requestNavigationCameraToOverview() + // navigationCamera.requestNavigationCameraToOverview() mapboxNavigation?.startTripSession(withForegroundService = true) } @@ -764,18 +744,16 @@ class MapboxNavigationView(private val context: ThemedReactContext): FrameLayout mapboxNavigation?.setNavigationRoutes(listOf()) // hide UI elements - binding.soundButton.visibility = View.INVISIBLE - binding.maneuverView.visibility = View.INVISIBLE - binding.routeOverview.visibility = View.INVISIBLE - binding.tripProgressCard.visibility = View.INVISIBLE + binding.soundButton.visibility = INVISIBLE + binding.maneuverView.visibility = INVISIBLE + binding.routeOverview.visibility = INVISIBLE + binding.tripProgressCard.visibility = INVISIBLE } private fun sendErrorToReact(error: String?) { val event = Arguments.createMap() event.putString("error", error) - context - .getJSModule(RCTEventEmitter::class.java) - .receiveEvent(id, "onError", event) + context.getJSModule(RCTEventEmitter::class.java).receiveEvent(id, "onError", event) } fun onDropViewInstance() { @@ -820,16 +798,192 @@ class MapboxNavigationView(private val context: ThemedReactContext): FrameLayout } fun setShowCancelButton(show: Boolean) { - binding.stop.visibility = if (show) View.VISIBLE else View.INVISIBLE + binding.stop.visibility = if (show) VISIBLE else INVISIBLE } fun setTravelMode(mode: String) { - travelMode = when (mode.lowercase()) { + travelMode = + when (mode.lowercase()) { "walking" -> DirectionsCriteria.PROFILE_WALKING "cycling" -> DirectionsCriteria.PROFILE_CYCLING "driving" -> DirectionsCriteria.PROFILE_DRIVING "driving-traffic" -> DirectionsCriteria.PROFILE_DRIVING_TRAFFIC else -> DirectionsCriteria.PROFILE_DRIVING_TRAFFIC + } + } + + override fun participantsDidUpdate(list: List>) { + runOnUiThread { + val mapView = binding.mapView ?: return@runOnUiThread + if (pointAnnotationManager == null) { + pointAnnotationManager = + mapView.annotations.createPointAnnotationManager().apply { + iconPitchAlignment = IconPitchAlignment.MAP + } + } + val seenIds = mutableSetOf() + val annotations = ArrayList() + list.forEach { user -> + val id = user["id"] as? String ?: return@forEach + val displayName = user["displayName"] as? String ?: return@forEach + val lat = user["lat"] as? Double ?: return@forEach + val lng = user["lng"] as? Double ?: return@forEach + val imageUrl = user["imageUrl"] as? String ?: "" + var bitmap: Bitmap? = null + val newPoint = Point.fromLngLat(lng, lat) + + val imageUrlLocal = cacheManager.get(id) + if (imageUrlLocal == null) { + downloadImageToTemp(context, imageUrl, id) { response -> + val result = response.getOrNull() + if (result == null) { + Log.e("ImageLoader", "❌ Failed to download:") + } else if (result != null) { + // ✅ Cache it + cacheManager.put(id, result.second); + bitmap = result.first; + } + } + } else { + val ouputBitmap = BitmapFactory.decodeFile(imageUrlLocal.path) + bitmap = ouputBitmap + } + + seenIds.add(id) + + val existingAnnotation = userAnnotations[id] + if (bitmap != null) { + if (existingAnnotation != null) { + // Animate from old position to new + animators[id]?.cancel() + val animator = + ValueAnimator.ofObject(CarEvaluator(), existingAnnotation.point, newPoint).apply { + duration = 1000L // 1 second + interpolator = LinearInterpolator() + addUpdateListener { valueAnimator -> + val animatedPoint = valueAnimator.animatedValue as Point + existingAnnotation.point = animatedPoint + pointAnnotationManager?.update(listOf(existingAnnotation)) + } + start() + } + animators[id] = animator + } else { + // New user → create annotation + val annotation = + PointAnnotationOptions() + .withPoint(newPoint) + .withIconImage(bitmap!!) + + .let { pointAnnotationManager!!.create(it) } + annotations.add(annotation) + userAnnotations[id] = annotation + } + } + } + pointAnnotationManager?.update(annotations) + } + } + + fun bitmapFromFile(path: String): Bitmap { + return try { + BitmapFactory.decodeFile(path) + } catch (e: Exception) { + return BitmapFactory.decodeResource(context.resources, R.drawable.ic_user_offline) + } + } + + + fun downloadImageToTemp( + context: Context, + urlString: String, + userId: String, + completion: (Result>) -> Unit + ) { + val tempDir = context.cacheDir + val file = File(tempDir, "$userId.jpg") + + // If already exists, return immediately + if (file.exists()) { + val bitmap = BitmapFactory.decodeFile(file.absolutePath) + completion(Result.success(Pair(bitmap, Uri.fromFile(file)))) + return + } + + thread { + try { + val url = URL(urlString) + val connection = url.openConnection() as HttpURLConnection + connection.doInput = true + connection.connect() + + val inputStream = connection.inputStream + val originalBitmap = BitmapFactory.decodeStream(inputStream) + + if (originalBitmap == null) { + completion(Result.failure(Exception("Invalid image data"))) + return@thread + } + + // Apply rounded corners + border (like Swift version) + val rounded = + originalBitmap.withRounded(borderWidth = 5f, borderColor = Color.WHITE) + + // Save bitmap to temp file + FileOutputStream(file).use { out -> + rounded.compress(Bitmap.CompressFormat.PNG, 100, out) + } + + completion(Result.success(Pair(rounded, Uri.fromFile(file)))) + + } catch (e: Exception) { + completion(Result.failure(e)) + } } } + + fun Bitmap.withRounded( + borderWidth: Float = 5f, + borderColor: Int = Color.WHITE, + targetSize: Int = 180 + ): Bitmap { + val output = Bitmap.createBitmap(targetSize, targetSize, Bitmap.Config.ARGB_8888) + val canvas = Canvas(output) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + // Step 1: Crop the bitmap to a square (centered) + val squareSize = minOf(width, height) + val xOffset = (width - squareSize) / 2 + val yOffset = (height - squareSize) / 2 + val squareBitmap = Bitmap.createBitmap(this, xOffset, yOffset, squareSize, squareSize) + + // Step 2: Scale the square bitmap to target size + val scaledBitmap = Bitmap.createScaledBitmap(squareBitmap, targetSize, targetSize, true) + + // Step 3: Draw circular image using BitmapShader + val shader = BitmapShader(scaledBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) + paint.shader = shader + val radius = targetSize / 2f + canvas.drawCircle(radius, radius, radius - borderWidth, paint) + + // Step 4: Draw border + if (borderWidth > 0) { + val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + color = borderColor + strokeWidth = borderWidth + } + canvas.drawCircle(radius, radius, radius - borderWidth , borderPaint) + } + + return output + } +} + +private class CarEvaluator : TypeEvaluator { + override fun evaluate(fraction: Float, startValue: Point, endValue: Point): Point { + val lat = startValue.latitude() + (endValue.latitude() - startValue.latitude()) * fraction + val lon = startValue.longitude() + (endValue.longitude() - startValue.longitude()) * fraction + return Point.fromLngLat(lon, lat) + } } diff --git a/android/src/main/java/com/mapboxnavigation/ParticipantsManager.kt b/android/src/main/java/com/mapboxnavigation/ParticipantsManager.kt new file mode 100644 index 0000000..77c705a --- /dev/null +++ b/android/src/main/java/com/mapboxnavigation/ParticipantsManager.kt @@ -0,0 +1,48 @@ +package com.mapboxnavigation + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.WritableArray +import com.facebook.react.modules.core.DeviceEventManagerModule + +class ParticipantsManager(private val context: ReactApplicationContext) : + ReactContextBaseJavaModule(context) { + + companion object { + var shared: ParticipantsManager? = null + } + + private var participants: List> = emptyList() + + interface ParticipantsDelegate { + fun participantsDidUpdate(list: List>) + } + var delegate: ParticipantsDelegate? = null + init { + shared = this + } + + override fun getName(): String = "ParticipantsManager" + + @ReactMethod + fun updateParticipants(list: ReadableArray) { + val participants = mutableListOf>() + + for (i in 0 until list.size()) { + val map = list.getMap(i) ?: continue + val convertedMap = map.toHashMap() + participants.add(convertedMap) + } + + // Now you can use 'participants' as List> + this.participants = participants + delegate?.participantsDidUpdate(participants) +// context.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) +// .emit("onParticipantsUpdate", list) + } + + fun getParticipants(): List> = participants +} diff --git a/android/src/main/res/drawable/ic_user_offline.png b/android/src/main/res/drawable/ic_user_offline.png new file mode 100644 index 0000000..709d260 Binary files /dev/null and b/android/src/main/res/drawable/ic_user_offline.png differ diff --git a/android/src/main/res/drawable/ic_user_pin.png b/android/src/main/res/drawable/ic_user_pin.png new file mode 100644 index 0000000..d7f68e4 Binary files /dev/null and b/android/src/main/res/drawable/ic_user_pin.png differ diff --git a/example/android/build.gradle b/example/android/build.gradle index 53f4983..ee41882 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -25,7 +25,7 @@ allprojects { authentication { basic(BasicAuthentication) } - credentials { + credentials { // Do not change the username below. // This should always be `mapbox` (not your username). username = "mapbox" @@ -34,8 +34,8 @@ allprojects { if (password == null || password == "") { throw new GradleException("MAPBOX_DOWNLOADS_TOKEN isn't set. Set it to the project properties or to the enviroment variables.") } - } } + } } } diff --git a/example/ios/.mapbox b/example/ios/.mapbox new file mode 100644 index 0000000..e69de29 diff --git a/example/ios/MapboxNavigationExample.xcodeproj/project.pbxproj b/example/ios/MapboxNavigationExample.xcodeproj/project.pbxproj index 3e6baee..0c32725 100644 --- a/example/ios/MapboxNavigationExample.xcodeproj/project.pbxproj +++ b/example/ios/MapboxNavigationExample.xcodeproj/project.pbxproj @@ -192,7 +192,6 @@ 13B07F8C1A680F5B00A75B9A /* Frameworks */, 13B07F8E1A680F5B00A75B9A /* Resources */, 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, - 87EEACE02C888669004404F6 /* Apply Mapbox token */, AF5EAC6B1E489AC4D128D26C /* [CP] Embed Pods Frameworks */, EDDB9EE463CF89A4404EC24B /* [CP] Copy Pods Resources */, ); @@ -308,33 +307,18 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-MapboxNavigationExample-MapboxNavigationExampleTests/Pods-MapboxNavigationExample-MapboxNavigationExampleTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-MapboxNavigationExample-MapboxNavigationExampleTests/Pods-MapboxNavigationExample-MapboxNavigationExampleTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-MapboxNavigationExample-MapboxNavigationExampleTests/Pods-MapboxNavigationExample-MapboxNavigationExampleTests-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - 87EEACE02C888669004404F6 /* Apply Mapbox token */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); inputPaths = ( - "$(TARGET_BUILD_DIR)/$(INFOPLIST_PATH)", ); - name = "Apply Mapbox token"; + name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-MapboxNavigationExample-MapboxNavigationExampleTests/Pods-MapboxNavigationExample-MapboxNavigationExampleTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "# This Run Script build phase helps to keep the navigation SDK’s developers from exposing their own access tokens during development. See for more information. If you are developing an application privately, you may add the MBXAccessToken key directly to your Info.plist file and delete this build phase.\n\n\"${SRCROOT}/apply_mapbox_token.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-MapboxNavigationExample-MapboxNavigationExampleTests/Pods-MapboxNavigationExample-MapboxNavigationExampleTests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; }; AF5EAC6B1E489AC4D128D26C /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; @@ -344,10 +328,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-MapboxNavigationExample/Pods-MapboxNavigationExample-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-MapboxNavigationExample/Pods-MapboxNavigationExample-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-MapboxNavigationExample/Pods-MapboxNavigationExample-frameworks.sh\"\n"; @@ -361,10 +349,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-MapboxNavigationExample-MapboxNavigationExampleTests/Pods-MapboxNavigationExample-MapboxNavigationExampleTests-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-MapboxNavigationExample-MapboxNavigationExampleTests/Pods-MapboxNavigationExample-MapboxNavigationExampleTests-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-MapboxNavigationExample-MapboxNavigationExampleTests/Pods-MapboxNavigationExample-MapboxNavigationExampleTests-resources.sh\"\n"; @@ -400,10 +392,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-MapboxNavigationExample/Pods-MapboxNavigationExample-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-MapboxNavigationExample/Pods-MapboxNavigationExample-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-MapboxNavigationExample/Pods-MapboxNavigationExample-resources.sh\"\n"; @@ -666,10 +662,7 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", ); - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; OTHER_SWIFT_FLAGS = "-D DEBUG"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; @@ -753,10 +746,7 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", ); - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; OTHER_SWIFT_FLAGS = "-D RELEASE"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; diff --git a/example/ios/MapboxNavigationExample/Images.xcassets/AppIcon.appiconset/Contents.json b/example/ios/MapboxNavigationExample/Images.xcassets/AppIcon.appiconset/Contents.json index 8121323..ddd7fca 100644 --- a/example/ios/MapboxNavigationExample/Images.xcassets/AppIcon.appiconset/Contents.json +++ b/example/ios/MapboxNavigationExample/Images.xcassets/AppIcon.appiconset/Contents.json @@ -1,53 +1,53 @@ { - "images" : [ + "images": [ { - "idiom" : "iphone", - "scale" : "2x", - "size" : "20x20" + "idiom": "iphone", + "scale": "2x", + "size": "20x20" }, { - "idiom" : "iphone", - "scale" : "3x", - "size" : "20x20" + "idiom": "iphone", + "scale": "3x", + "size": "20x20" }, { - "idiom" : "iphone", - "scale" : "2x", - "size" : "29x29" + "idiom": "iphone", + "scale": "2x", + "size": "29x29" }, { - "idiom" : "iphone", - "scale" : "3x", - "size" : "29x29" + "idiom": "iphone", + "scale": "3x", + "size": "29x29" }, { - "idiom" : "iphone", - "scale" : "2x", - "size" : "40x40" + "idiom": "iphone", + "scale": "2x", + "size": "40x40" }, { - "idiom" : "iphone", - "scale" : "3x", - "size" : "40x40" + "idiom": "iphone", + "scale": "3x", + "size": "40x40" }, { - "idiom" : "iphone", - "scale" : "2x", - "size" : "60x60" + "idiom": "iphone", + "scale": "2x", + "size": "60x60" }, { - "idiom" : "iphone", - "scale" : "3x", - "size" : "60x60" + "idiom": "iphone", + "scale": "3x", + "size": "60x60" }, { - "idiom" : "ios-marketing", - "scale" : "1x", - "size" : "1024x1024" + "idiom": "ios-marketing", + "scale": "1x", + "size": "1024x1024" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } } diff --git a/example/ios/MapboxNavigationExample/Images.xcassets/Contents.json b/example/ios/MapboxNavigationExample/Images.xcassets/Contents.json index 2d92bd5..74d6a72 100644 --- a/example/ios/MapboxNavigationExample/Images.xcassets/Contents.json +++ b/example/ios/MapboxNavigationExample/Images.xcassets/Contents.json @@ -1,6 +1,6 @@ { - "info" : { - "version" : 1, - "author" : "xcode" + "info": { + "author": "xcode", + "version": 1 } } diff --git a/example/ios/MapboxNavigationExample/Images.xcassets/user_active_pin.imageset/Contents.json b/example/ios/MapboxNavigationExample/Images.xcassets/user_active_pin.imageset/Contents.json new file mode 100644 index 0000000..ddb2d3a --- /dev/null +++ b/example/ios/MapboxNavigationExample/Images.xcassets/user_active_pin.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images": [ + { + "filename": "dest-pin 3.png", + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "dest-pin 1.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "dest-pin 2.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/example/ios/MapboxNavigationExample/Images.xcassets/user_active_pin.imageset/dest-pin 1.png b/example/ios/MapboxNavigationExample/Images.xcassets/user_active_pin.imageset/dest-pin 1.png new file mode 100644 index 0000000..d7f68e4 Binary files /dev/null and b/example/ios/MapboxNavigationExample/Images.xcassets/user_active_pin.imageset/dest-pin 1.png differ diff --git a/example/ios/MapboxNavigationExample/Images.xcassets/user_active_pin.imageset/dest-pin 2.png b/example/ios/MapboxNavigationExample/Images.xcassets/user_active_pin.imageset/dest-pin 2.png new file mode 100644 index 0000000..d7f68e4 Binary files /dev/null and b/example/ios/MapboxNavigationExample/Images.xcassets/user_active_pin.imageset/dest-pin 2.png differ diff --git a/example/ios/MapboxNavigationExample/Images.xcassets/user_active_pin.imageset/dest-pin 3.png b/example/ios/MapboxNavigationExample/Images.xcassets/user_active_pin.imageset/dest-pin 3.png new file mode 100644 index 0000000..d7f68e4 Binary files /dev/null and b/example/ios/MapboxNavigationExample/Images.xcassets/user_active_pin.imageset/dest-pin 3.png differ diff --git a/example/ios/MapboxNavigationExample/Images.xcassets/user_offline_pin.imageset/Contents.json b/example/ios/MapboxNavigationExample/Images.xcassets/user_offline_pin.imageset/Contents.json new file mode 100644 index 0000000..80385c3 --- /dev/null +++ b/example/ios/MapboxNavigationExample/Images.xcassets/user_offline_pin.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images": [ + { + "filename": "dest-pin.png", + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "dest-pin 1.png", + "idiom": "universal", + "scale": "2x" + }, + { + "filename": "dest-pin 2.png", + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/example/ios/MapboxNavigationExample/Images.xcassets/user_offline_pin.imageset/dest-pin 1.png b/example/ios/MapboxNavigationExample/Images.xcassets/user_offline_pin.imageset/dest-pin 1.png new file mode 100644 index 0000000..709d260 Binary files /dev/null and b/example/ios/MapboxNavigationExample/Images.xcassets/user_offline_pin.imageset/dest-pin 1.png differ diff --git a/example/ios/MapboxNavigationExample/Images.xcassets/user_offline_pin.imageset/dest-pin 2.png b/example/ios/MapboxNavigationExample/Images.xcassets/user_offline_pin.imageset/dest-pin 2.png new file mode 100644 index 0000000..709d260 Binary files /dev/null and b/example/ios/MapboxNavigationExample/Images.xcassets/user_offline_pin.imageset/dest-pin 2.png differ diff --git a/example/ios/MapboxNavigationExample/Images.xcassets/user_offline_pin.imageset/dest-pin.png b/example/ios/MapboxNavigationExample/Images.xcassets/user_offline_pin.imageset/dest-pin.png new file mode 100644 index 0000000..709d260 Binary files /dev/null and b/example/ios/MapboxNavigationExample/Images.xcassets/user_offline_pin.imageset/dest-pin.png differ diff --git a/example/ios/MapboxNavigationExample/Info.plist b/example/ios/MapboxNavigationExample/Info.plist index 0687b98..f94e8c0 100644 --- a/example/ios/MapboxNavigationExample/Info.plist +++ b/example/ios/MapboxNavigationExample/Info.plist @@ -24,8 +24,6 @@ $(CURRENT_PROJECT_VERSION) LSRequiresIPhoneOS - MBXAccessToken - MapboxToken NSAppTransportSecurity NSAllowsArbitraryLoads diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 3ce2f09..ae5deeb 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -959,7 +959,7 @@ PODS: - React-Mapbuffer (0.74.3): - glog - React-debug - - react-native-mapbox-navigation (0.4.4): + - react-native-mapbox-navigation (0.5.2): - DoubleConversion - glog - hermes-engine @@ -1444,7 +1444,7 @@ SPEC CHECKSUMS: React-jsitracing: 6b3c8c98313642140530f93c46f5a6ca4530b446 React-logger: fa92ba4d3a5d39ac450f59be2a3cec7b099f0304 React-Mapbuffer: 9f68550e7c6839d01411ac8896aea5c868eff63a - react-native-mapbox-navigation: 67c175cb05d646ebe02b77b9a6ae974ab185dba3 + react-native-mapbox-navigation: 6d261acd1d0234b7ca1fc672d94ca23461c2e363 React-nativeconfig: fa5de9d8f4dbd5917358f8ad3ad1e08762f01dcb React-NativeModulesApple: 585d1b78e0597de364d259cb56007052d0bda5e5 React-perflogger: 7bb9ba49435ff66b666e7966ee10082508a203e8 @@ -1471,8 +1471,8 @@ SPEC CHECKSUMS: SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d Solar-dev: 4612dc9878b9fed2667d23b327f1d4e54e16e8d0 Turf: aa2ede4298009639d10db36aba1a7ebaad072a5e - Yoga: 04f1db30bb810187397fa4c37dd1868a27af229c + Yoga: 88480008ccacea6301ff7bf58726e27a72931c8d PODFILE CHECKSUM: c745c267888f56367ee22ec85344a16a407b6acb -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/example/src/App.tsx b/example/src/App.tsx index 2d79d7a..d76f440 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,10 +1,41 @@ -import { Button, StyleSheet, Text, View } from 'react-native'; +import { Button, NativeModules, StyleSheet, Text, View } from 'react-native'; import MapboxNavigation from '@pawan-pk/react-native-mapbox-navigation'; -import { useState } from 'react'; - +import { useEffect, useRef, useState } from 'react'; +import useRandomUsers from './realTimeList'; +const { ParticipantsManager } = NativeModules; +interface Coordinates { + latitude: number; + longitude: number; +} export default function App() { const [navigating, setNavigating] = useState(false); + const participants = useRandomUsers(); // This changes over time + const startOrigin: Coordinates = { latitude: 28.4212, longitude: 70.2989 }; + const destination: Coordinates = { latitude: 31.5204, longitude: 74.3587 }; + const waypoints: Coordinates[] = []; + const [delay, setDelay] = useState(false); + // 🆕 keep track of last synced participants + const lastParticipantsRef = useRef(null); + + useEffect(() => { + const timer = setTimeout(() => setDelay(true), 100); + return () => clearTimeout(timer); + }, []); + useEffect(() => { + if (!ParticipantsManager) { + console.error('❌ ParticipantsManager not found'); + return; + } + const serialized = JSON.stringify(participants); + if (serialized !== lastParticipantsRef.current) { + lastParticipantsRef.current = serialized; + ParticipantsManager.updateParticipants(participants); + console.log('✅ Updated participants:', participants.length); + } else { + console.log('⚡ Skipped duplicate participants update'); + } + }, [participants]); if (!navigating) { return ( @@ -24,37 +55,18 @@ export default function App() { return ( { setNavigating(false); }} @@ -64,6 +76,9 @@ export default function App() { onError={(error) => { console.log('onError', error); }} + onLocationChange={(location) => { + console.log('onLocationChange', location); + }} /> ); } @@ -81,4 +96,8 @@ const styles = StyleSheet.create({ marginBottom: 20, textAlign: 'center', }, + map: { + backgroundColor: 'white', + flex: 1, + }, }); diff --git a/example/src/Participant.ts b/example/src/Participant.ts new file mode 100644 index 0000000..48ee934 --- /dev/null +++ b/example/src/Participant.ts @@ -0,0 +1,12 @@ +export interface Participant { + id: string; + userMail: string; + coverImage: string; + displayName: string; + imageUrl: string; + isBenzifiMember: boolean; + nation: string; + userName: string; + lat: number; // starting latitude + lng: number; // starting longitude +} diff --git a/example/src/Points.ts b/example/src/Points.ts new file mode 100644 index 0000000..86be340 --- /dev/null +++ b/example/src/Points.ts @@ -0,0 +1,32 @@ +export const Points: [number, number][] = [ + [70.298939, 28.421246], + [70.298792, 28.421181], + [70.295615, 28.422413], + [70.265977, 28.539847], + [70.265643, 28.539444], + [70.266367, 28.540479], + [70.259943, 28.577665], + [70.237826, 28.637858], + [70.236305, 28.638427], + [71.197527, 29.363616], + [71.334668, 29.624111], + [71.401108, 29.891232], + [71.37731, 30.066991], + [71.829678, 30.254591], + [72.124639, 30.64367], + [73.364253, 31.185353], + [73.676193, 31.370425], + [74.224858, 31.571651], + [74.239728, 31.559351], + [74.260817, 31.541392], + [74.264865, 31.538964], + [74.287482, 31.531906], + [74.28379, 31.52611], + [74.316776, 31.500769], + [74.326386, 31.502964], + [74.34759, 31.523117], + [74.347042, 31.521591], + [74.351174, 31.520956], + [74.355008, 31.523286], + [74.358707, 31.520403], +]; diff --git a/example/src/dummyImageURLs.ts b/example/src/dummyImageURLs.ts new file mode 100644 index 0000000..32387ca --- /dev/null +++ b/example/src/dummyImageURLs.ts @@ -0,0 +1,18 @@ +// Generate initial 10 users within start-destination bounds +export const dummyImageURLs: string[] = [ + 'https://picsum.photos/id/237/400/300', // Dog + 'https://picsum.photos/id/1003/400/300', // Mountains + 'https://picsum.photos/id/1025/400/300', // Parrot + 'https://picsum.photos/id/1069/400/300', // Bridge + 'https://picsum.photos/id/1074/400/300', // Forest + 'https://picsum.photos/id/1084/400/300', // Sea horizon + 'https://picsum.photos/id/1080/400/300', // Beach + 'https://picsum.photos/id/1081/400/300', // Rock formations + 'https://picsum.photos/id/1082/400/300', // River + 'https://picsum.photos/id/1083/400/300', // Desert + 'https://picsum.photos/id/1085/400/300', // Foggy forest + 'https://picsum.photos/id/1089/400/300', // Sunset + 'https://picsum.photos/id/109/400/300', // Old building + 'https://picsum.photos/id/111/400/300', // Trees + 'https://picsum.photos/id/112/400/300', // Skyline +]; diff --git a/example/src/realTimeList.tsx b/example/src/realTimeList.tsx new file mode 100644 index 0000000..5a10191 --- /dev/null +++ b/example/src/realTimeList.tsx @@ -0,0 +1,60 @@ +import { useEffect, useState } from 'react'; +import type { Participant } from './Participant'; +import { dummyImageURLs } from './dummyImageURLs'; +import { Points } from './Points'; +export default function useRandomUsers() { + const startOrigin = { latitude: 28.4212, longitude: 70.2989 }; + const destination = { latitude: 31.5204, longitude: 74.3587 }; + const [participants, setParticipants] = useState([]); + + useEffect(() => { + const users: Participant[] = Array.from({ length: 10 }).map((_, idx) => ({ + id: `user-${idx + 1}`, + userMail: `user${idx + 1}@example.com`, + coverImage: '', + displayName: `User ${idx + 1}`, + imageUrl: dummyImageURLs[idx] ?? 'https://picsum.photos/id/112/400/300', + isBenzifiMember: false, + nation: 'AE', + userName: `user${idx + 1}`, + lat: startOrigin.latitude, + lng: startOrigin.longitude, + })); + setParticipants(users); + }, [ + startOrigin.latitude, + startOrigin.longitude, + destination.latitude, + destination.longitude, + ]); + + // Track user index along the path + useEffect(() => { + const interval = setInterval(() => { + setParticipants((prev) => + prev.map((user, idx) => { + // Each user has an implicit "path index" + // Example: user-1 => pathIndex = tick + 0, user-2 => tick + 1 + const currentTick = Date.now() / 5000; // every 5 sec + const step = Math.floor(currentTick) + idx; + + const pathIndex = step % Points.length; // loop around + const [lng, lat] = Points[pathIndex]!; + + return { + ...user, + lat, + lng, + }; + }) + ); + }, 5000); + return () => clearInterval(interval); + }, [ + startOrigin.latitude, + startOrigin.longitude, + destination.latitude, + destination.longitude, + ]); + return participants; +} diff --git a/ios/LRUCache.swift b/ios/LRUCache.swift new file mode 100644 index 0000000..aef2fb9 --- /dev/null +++ b/ios/LRUCache.swift @@ -0,0 +1,96 @@ +import Foundation + +final class LRUCache { + + private class Node { + let key: Key + var value: Value + var prev: Node? + var next: Node? + + init(key: Key, value: Value) { + self.key = key + self.value = value + } + } + + private let capacity: Int + private var dict: [Key: Node] = [:] + private var head: Node? // Most recently used + private var tail: Node? // Least recently used + private let lock = NSLock() + + init(capacity: Int) { + precondition(capacity > 0, "LRUCache capacity must be greater than 0") + self.capacity = capacity + } + + func get(_ key: Key) -> Value? { + lock.lock() + defer { lock.unlock() } + + guard let node = dict[key] else { return nil } + moveToHead(node) + return node.value + } + + func put(_ key: Key, value: Value) { + lock.lock() + defer { lock.unlock() } + + if let node = dict[key] { + node.value = value + moveToHead(node) + } else { + let newNode = Node(key: key, value: value) + dict[key] = newNode + addToHead(newNode) + + if dict.count > capacity { + if let tail = removeTail() { + dict.removeValue(forKey: tail.key) + } + } + } + } + + // MARK: - Private helpers + + private func moveToHead(_ node: Node) { + removeNode(node) + addToHead(node) + } + + private func addToHead(_ node: Node) { + node.prev = nil + node.next = head + head?.prev = node + head = node + if tail == nil { + tail = node + } + } + + private func removeNode(_ node: Node) { + let prev = node.prev + let next = node.next + + if let prev = prev { + prev.next = next + } else { + head = next + } + + if let next = next { + next.prev = prev + } else { + tail = prev + } + } + + private func removeTail() -> Node? { + guard let tail = tail else { return nil } + removeNode(tail) + return tail + } +} \ No newline at end of file diff --git a/ios/MapboxNavigationView.swift b/ios/MapboxNavigationView.swift index ef7d9a8..f682878 100644 --- a/ios/MapboxNavigationView.swift +++ b/ios/MapboxNavigationView.swift @@ -2,6 +2,7 @@ import MapboxCoreNavigation import MapboxNavigation import MapboxDirections +import MapboxMaps extension UIView { var parentViewController: UIViewController? { @@ -26,183 +27,278 @@ public protocol MapboxCarPlayNavigationDelegate { func endNavigation() } -public class MapboxNavigationView: UIView, NavigationViewControllerDelegate { - public weak var navViewController: NavigationViewController? - public var indexedRouteResponse: IndexedRouteResponse? - - var embedded: Bool - var embedding: Bool +public class MapboxNavigationView: UIView, NavigationViewControllerDelegate, ParticipantsManagerDelegate { + public weak var navViewController: NavigationViewController? + public var indexedRouteResponse: IndexedRouteResponse? - @objc public var startOrigin: NSArray = [] { - didSet { setNeedsLayout() } - } - - var waypoints: [Waypoint] = [] { - didSet { setNeedsLayout() } - } - - func setWaypoints(waypoints: [MapboxWaypoint]) { - self.waypoints = waypoints.enumerated().map { (index, waypointData) in - let name = waypointData.name as? String ?? "\(index)" - let waypoint = Waypoint(coordinate: waypointData.coordinate, name: name) - waypoint.separatesLegs = waypointData.separatesLegs - return waypoint - } - } - - @objc var destination: NSArray = [] { - didSet { setNeedsLayout() } - } - - @objc var shouldSimulateRoute: Bool = false - @objc var showsEndOfRouteFeedback: Bool = false - @objc var showCancelButton: Bool = false - @objc var hideStatusView: Bool = false - @objc var mute: Bool = false - @objc var distanceUnit: NSString = "imperial" - @objc var language: NSString = "us" - @objc var destinationTitle: NSString = "Destination" - @objc var travelMode: NSString = "driving-traffic" - - @objc var onLocationChange: RCTDirectEventBlock? - @objc var onRouteProgressChange: RCTDirectEventBlock? - @objc var onError: RCTDirectEventBlock? - @objc var onCancelNavigation: RCTDirectEventBlock? - @objc var onArrive: RCTDirectEventBlock? - @objc var vehicleMaxHeight: NSNumber? - @objc var vehicleMaxWidth: NSNumber? - - override init(frame: CGRect) { - self.embedded = false - self.embedding = false - super.init(frame: frame) - } + var embedded: Bool + var embedding: Bool - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") + @objc public var startOrigin: NSArray = [] { + didSet { setNeedsLayout() } + } + + var waypoints: [Waypoint] = [] { + didSet { setNeedsLayout() } + } + + func setWaypoints(waypoints: [MapboxWaypoint]) { + self.waypoints = waypoints.enumerated().map { (index, waypointData) in + let name = waypointData.name as? String ?? "\(index)" + let waypoint = Waypoint(coordinate: waypointData.coordinate, name: name) + waypoint.separatesLegs = waypointData.separatesLegs + return waypoint } + } - public override func layoutSubviews() { - super.layoutSubviews() + @objc var destination: NSArray = [] { + didSet { setNeedsLayout() } + } - if (navViewController == nil && !embedding && !embedded) { - embed() - } else { - navViewController?.view.frame = bounds - } + @objc var shouldSimulateRoute: Bool = false + @objc var showsEndOfRouteFeedback: Bool = false + @objc var showCancelButton: Bool = false + @objc var hideStatusView: Bool = false + @objc var mute: Bool = false + @objc var distanceUnit: NSString = "imperial" + @objc var language: NSString = "us" + @objc var destinationTitle: NSString = "Destination" + @objc var travelMode: NSString = "driving-traffic" + + @objc var onLocationChange: RCTDirectEventBlock? + @objc var onRouteProgressChange: RCTDirectEventBlock? + @objc var onError: RCTDirectEventBlock? + @objc var onCancelNavigation: RCTDirectEventBlock? + @objc var onArrive: RCTDirectEventBlock? + @objc var vehicleMaxHeight: NSNumber? + @objc var vehicleMaxWidth: NSNumber? + var pointAnnotationManager: PointAnnotationManager? + var cacheManager: LRUCache = LRUCache(capacity: 10000) + + override init(frame: CGRect) { + self.embedded = false + self.embedding = false + super.init(frame: frame) + ParticipantsManager.shared?.delegate = self + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func layoutSubviews() { + super.layoutSubviews() + + if (navViewController == nil && !embedding && !embedded) { + embed() + } else { + navViewController?.view.frame = bounds } + } - public override func removeFromSuperview() { - super.removeFromSuperview() - // cleanup and teardown any existing resources - self.navViewController?.removeFromParent() - - // MARK: End CarPlay Navigation - if let carPlayNavigation = UIApplication.shared.delegate as? MapboxCarPlayNavigationDelegate { - carPlayNavigation.endNavigation() - } - NotificationCenter.default.removeObserver(self, name: .navigationSettingsDidChange, object: nil) + func participantsDidUpdate(_ list: [[String: Any]]) { + updateParticipantsOnMap(list) + } + + public override func removeFromSuperview() { + super.removeFromSuperview() + // cleanup and teardown any existing resources + self.navViewController?.removeFromParent() + + // MARK: End CarPlay Navigation + if let carPlayNavigation = UIApplication.shared.delegate as? MapboxCarPlayNavigationDelegate { + carPlayNavigation.endNavigation() } + NotificationCenter.default.removeObserver(self, name: .navigationSettingsDidChange, object: nil) + } - private func embed() { - guard startOrigin.count == 2 && destination.count == 2 else { return } + private func embed() { + guard startOrigin.count == 2 && destination.count == 2 else { return } - embedding = true + embedding = true - let originWaypoint = Waypoint(coordinate: CLLocationCoordinate2D(latitude: startOrigin[1] as! CLLocationDegrees, longitude: startOrigin[0] as! CLLocationDegrees)) - var waypointsArray = [originWaypoint] + let originWaypoint = Waypoint(coordinate: CLLocationCoordinate2D(latitude: startOrigin[1] as! CLLocationDegrees, longitude: startOrigin[0] as! CLLocationDegrees)) + var waypointsArray = [originWaypoint] - // Add Waypoints - waypointsArray.append(contentsOf: waypoints) + // Add Waypoints + waypointsArray.append(contentsOf: waypoints) - let destinationWaypoint = Waypoint(coordinate: CLLocationCoordinate2D(latitude: destination[1] as! CLLocationDegrees, longitude: destination[0] as! CLLocationDegrees), name: destinationTitle as String) - waypointsArray.append(destinationWaypoint) + let destinationWaypoint = Waypoint(coordinate: CLLocationCoordinate2D(latitude: destination[1] as! CLLocationDegrees, longitude: destination[0] as! CLLocationDegrees), name: destinationTitle as String) + waypointsArray.append(destinationWaypoint) - let profile: MBDirectionsProfileIdentifier + let profile: MBDirectionsProfileIdentifier - switch travelMode { - case "cycling": - profile = .cycling - case "walking": - profile = .walking - case "driving-traffic": - profile = .automobileAvoidingTraffic - default: - profile = .automobile - } + switch travelMode { + case "cycling": + profile = .cycling + case "walking": + profile = .walking + case "driving-traffic": + profile = .automobileAvoidingTraffic + default: + profile = .automobile + } - let options = NavigationRouteOptions(waypoints: waypointsArray, profileIdentifier: profile) + let options = NavigationRouteOptions(waypoints: waypointsArray, profileIdentifier: profile) - let locale = self.language.replacingOccurrences(of: "-", with: "_") - options.locale = Locale(identifier: locale) - options.distanceMeasurementSystem = distanceUnit == "imperial" ? .imperial : .metric + let locale = self.language.replacingOccurrences(of: "-", with: "_") + options.locale = Locale(identifier: locale) + options.distanceMeasurementSystem = distanceUnit == "imperial" ? .imperial : .metric - Directions.shared.calculateRoutes(options: options) { [weak self] result in - guard let strongSelf = self, let parentVC = strongSelf.parentViewController else { - return - } + Directions.shared.calculateRoutes(options: options) { [weak self] result in + guard let strongSelf = self, let parentVC = strongSelf.parentViewController else { + return + } - switch result { - case .failure(let error): - strongSelf.onError!(["message": error.localizedDescription]) - case .success(let response): - strongSelf.indexedRouteResponse = response - let navigationOptions = NavigationOptions(simulationMode: strongSelf.shouldSimulateRoute ? .always : .never) - let vc = NavigationViewController(for: response, navigationOptions: navigationOptions) + switch result { + case .failure(let error): + strongSelf.onError!(["message": error.localizedDescription]) + case .success(let response): + strongSelf.indexedRouteResponse = response + let navigationOptions = NavigationOptions(simulationMode: strongSelf.shouldSimulateRoute ? .always : .never) + let vc = NavigationViewController(for: response, navigationOptions: navigationOptions) - vc.showsEndOfRouteFeedback = strongSelf.showsEndOfRouteFeedback - StatusView.appearance().isHidden = strongSelf.hideStatusView + vc.showsEndOfRouteFeedback = strongSelf.showsEndOfRouteFeedback + StatusView.appearance().isHidden = strongSelf.hideStatusView - NavigationSettings.shared.voiceMuted = strongSelf.mute - NavigationSettings.shared.distanceUnit = strongSelf.distanceUnit == "imperial" ? .mile : .kilometer + NavigationSettings.shared.voiceMuted = strongSelf.mute + NavigationSettings.shared.distanceUnit = strongSelf.distanceUnit == "imperial" ? .mile : .kilometer - vc.delegate = strongSelf + vc.delegate = strongSelf - parentVC.addChild(vc) - strongSelf.addSubview(vc.view) - vc.view.frame = strongSelf.bounds - vc.didMove(toParent: parentVC) - strongSelf.navViewController = vc - } + parentVC.addChild(vc) + strongSelf.addSubview(vc.view) + vc.view.frame = strongSelf.bounds + vc.didMove(toParent: parentVC) + strongSelf.navViewController = vc + } - strongSelf.embedding = false - strongSelf.embedded = true - - // MARK: Start CarPlay Navigation - if let carPlayNavigation = UIApplication.shared.delegate as? MapboxCarPlayNavigationDelegate { - carPlayNavigation.startNavigation(with: strongSelf) - } - } + strongSelf.embedding = false + strongSelf.embedded = true + + // MARK: Start CarPlay Navigation + if let carPlayNavigation = UIApplication.shared.delegate as? MapboxCarPlayNavigationDelegate { + carPlayNavigation.startNavigation(with: strongSelf) + } } + } - public func navigationViewController(_ navigationViewController: NavigationViewController, didUpdate progress: RouteProgress, with location: CLLocation, rawLocation: CLLocation) { - onLocationChange?([ - "longitude": location.coordinate.longitude, - "latitude": location.coordinate.latitude, - "heading": 0, - "accuracy": location.horizontalAccuracy.magnitude - ]) - onRouteProgressChange?([ - "distanceTraveled": progress.distanceTraveled, - "durationRemaining": progress.durationRemaining, - "fractionTraveled": progress.fractionTraveled, - "distanceRemaining": progress.distanceRemaining - ]) + public func navigationViewController(_ navigationViewController: NavigationViewController, didUpdate progress: RouteProgress, with location: CLLocation, rawLocation: CLLocation) { + onLocationChange?([ + "longitude": location.coordinate.longitude, + "latitude": location.coordinate.latitude, + "heading": 0, + "accuracy": location.horizontalAccuracy.magnitude + ]) + onRouteProgressChange?([ + "distanceTraveled": progress.distanceTraveled, + "durationRemaining": progress.durationRemaining, + "fractionTraveled": progress.fractionTraveled, + "distanceRemaining": progress.distanceRemaining + ]) + } + + public func navigationViewControllerDidDismiss(_ navigationViewController: NavigationViewController, byCanceling canceled: Bool) { + if (!canceled) { + return; } + onCancelNavigation?(["message": "Navigation Cancel"]); + } + + public func navigationViewController(_ navigationViewController: NavigationViewController, didArriveAt waypoint: Waypoint) -> Bool { + onArrive?([ + "name": waypoint.name ?? waypoint.description, + "longitude": waypoint.coordinate.latitude, + "latitude": waypoint.coordinate.longitude, + ]) + return true; + } - public func navigationViewControllerDidDismiss(_ navigationViewController: NavigationViewController, byCanceling canceled: Bool) { - if (!canceled) { - return; + private func updateParticipantsOnMap(_ list: [[String: Any]]) { + guard let mapView = navViewController?.navigationMapView else { return } + if pointAnnotationManager == nil { + pointAnnotationManager = mapView.mapView.annotations.makePointAnnotationManager() + } + var uannotations: [PointAnnotation] = [] + for user in list { + guard + let id = user["id"] as? String, + let lat = user["lat"] as? Double, + let lng = user["lng"] as? Double + else { continue } + let imageUrl = user["imageUrl"] as? String ?? "" + let newCoordinate = CLLocationCoordinate2D(latitude: lat, longitude: lng) + var annotation = PointAnnotation(id: id, coordinate: newCoordinate) + if let imageUrl = cacheManager.get(id), let userImge = UIImage(contentsOfFile: imageUrl.path) { + annotation.image = .init(image: userImge, name: id) + } else if let image = imageFromURI(imageUrl,userId: id) { + annotation.image = .init(image: image.withRounded(radius: 10, borderWidth: 1, borderColor: UIColor.white), name: id) + } + annotation.userInfo = user + uannotations.append(annotation) + } + self.pointAnnotationManager?.annotations = uannotations + } + + func imageFromURI(_ uri: String, userId: String) -> UIImage? { + if uri.hasPrefix("asset:/") { + let name = uri.replacingOccurrences(of: "asset:/", with: "") + return UIImage(named: name) + } else if uri.hasPrefix("file://") { + let path = uri.replacingOccurrences(of: "file://", with: "") + return UIImage(contentsOfFile: path) + } else { + downloadImageToTemp(urlString: uri, userId: userId, completion: { [weak self,userId] result in + guard let self = self else { return } + switch result { + case .success(let success): + self.cacheManager.put(userId, value: success.1) + case .failure(let failure): + print(failure.localizedDescription) } - onCancelNavigation?(["message": "Navigation Cancel"]); + }) } + return nil + } - public func navigationViewController(_ navigationViewController: NavigationViewController, didArriveAt waypoint: Waypoint) -> Bool { - onArrive?([ - "name": waypoint.name ?? waypoint.description, - "longitude": waypoint.coordinate.latitude, - "latitude": waypoint.coordinate.longitude, - ]) - return true; + func downloadImageToTemp(urlString: String, userId: String , completion: @escaping (Result<(UIImage,URL), Error>) -> Void) { + let tempDir = FileManager.default.temporaryDirectory + let fileURL = tempDir.appendingPathComponent("\(userId).jpg") + if FileManager.default.fileExists(atPath: fileURL.path) { + completion(.success((UIImage(contentsOfFile: fileURL.path)!, fileURL))) + return + } + + guard let url = URL(string: urlString) else { + completion(.failure(NSError(domain: "InvalidURL", code: -1))) + return + } + + let task = URLSession.shared.dataTask(with: url) {[userId] data, response, error in + if let error = error { + completion(.failure(error)) + return + } + + guard let data = data, + let image = UIImage(data: data)?.withRounded(radius: 10, borderWidth: 1, borderColor: UIColor.white), + let newImageData = image.pngData() + else { + completion(.failure(NSError(domain: "InvalidImageData", code: -2))) + return + } + + // Create a temp file URL + let tempDir = FileManager.default.temporaryDirectory + let fileURL = tempDir.appendingPathComponent("\(userId).jpg") + + do { + try newImageData.write(to: fileURL) + completion(.success((image,fileURL))) + } catch { + completion(.failure(error)) + } } + task.resume() + } } diff --git a/ios/MapboxNavigationViewManager.swift b/ios/MapboxNavigationViewManager.swift index 11b240c..2cb3002 100644 --- a/ios/MapboxNavigationViewManager.swift +++ b/ios/MapboxNavigationViewManager.swift @@ -10,11 +10,11 @@ class MapboxNavigationViewManager: RCTViewManager { override func view() -> UIView! { return MapboxNavigationView(); } - + override static func requiresMainQueueSetup() -> Bool { return true } - + @objc(setWaypoints:waypoints:) public func setWaypoints(view: Any, waypoints: [MapboxWaypoint]) { guard let currentView = view as? MapboxNavigationView else { diff --git a/ios/ParticipantsManager.m b/ios/ParticipantsManager.m new file mode 100644 index 0000000..b1913c1 --- /dev/null +++ b/ios/ParticipantsManager.m @@ -0,0 +1,8 @@ +#import +#import + +@interface RCT_EXTERN_MODULE(ParticipantsManager, RCTEventEmitter) + +RCT_EXTERN_METHOD(updateParticipants:(NSArray *)list) + +@end \ No newline at end of file diff --git a/ios/ParticipantsManager.swift b/ios/ParticipantsManager.swift new file mode 100644 index 0000000..f564257 --- /dev/null +++ b/ios/ParticipantsManager.swift @@ -0,0 +1,59 @@ +import Foundation +import React + + + +protocol ParticipantsManagerDelegate: AnyObject { + func participantsDidUpdate(_ list: [[String: Any]]) +} + +@objc(ParticipantsManager) +class ParticipantsManager: RCTEventEmitter { + static var shared: ParticipantsManager? + + private var hasListeners = false + private var participants: [[String: Any]] = [] + weak var delegate: ParticipantsManagerDelegate? // 👈 native delegate + + override init() { + super.init() + ParticipantsManager.shared = self + print("✅ ParticipantsManager initialized") + } + + override static func moduleName() -> String! { + return "ParticipantsManager" + } + + override static func requiresMainQueueSetup() -> Bool { + return true + } + + override func supportedEvents() -> [String]! { + return ["onParticipantsUpdate"] + } + + override func startObserving() { + hasListeners = true + } + + override func stopObserving() { + hasListeners = false + } + + // Called from JS + @objc(updateParticipants:) + func updateParticipants(_ list: [[String: Any]]) { + participants = list + // Notify JS listeners + if hasListeners { + sendEvent(withName: "onParticipantsUpdate", body: list) + } + // Notify native listener + delegate?.participantsDidUpdate(list) + } + // Allow MapboxNavigationView to pull latest list + func getParticipants() -> [[String: Any]] { + return participants + } +} diff --git a/ios/RCTConvert+MapboxNavigation.m b/ios/RCTConvert+MapboxNavigation.m index eac9906..cddfd4d 100644 --- a/ios/RCTConvert+MapboxNavigation.m +++ b/ios/RCTConvert+MapboxNavigation.m @@ -23,5 +23,4 @@ + (MapboxWaypoint *)MapboxWaypoint:(id)json { RCT_ARRAY_CONVERTER(MapboxWaypoint) -@end - +@end \ No newline at end of file diff --git a/ios/UIImage+extensions.swift b/ios/UIImage+extensions.swift new file mode 100644 index 0000000..50999ff --- /dev/null +++ b/ios/UIImage+extensions.swift @@ -0,0 +1,39 @@ +import UIKit +extension UIImage { + func withRounded(radius: CGFloat, borderWidth: CGFloat = 0, borderColor: UIColor = UIColor.white) -> UIImage { + let targetSize = CGSize(width: 20, height: 20) + let rect = CGRect(origin: .zero, size: targetSize) + + UIGraphicsBeginImageContextWithOptions(targetSize, false, 0.0) + let context = UIGraphicsGetCurrentContext() + + let path = UIBezierPath(roundedRect: rect, cornerRadius: radius) + path.addClip() + + // Compute aspect-fit size + let aspectWidth = targetSize.width / size.width + let aspectHeight = targetSize.height / size.height + let scaleFactor = max(aspectWidth, aspectHeight) + + let scaledWidth = size.width * scaleFactor + let scaledHeight = size.height * scaleFactor + + // Center the image in the target rect + let x = (targetSize.width - scaledWidth) / 2 + let y = (targetSize.height - scaledHeight) / 2 + let drawRect = CGRect(x: x, y: y, width: scaledWidth, height: scaledHeight) + + self.draw(in: drawRect) + + if borderWidth > 0 { + borderColor.setStroke() + path.lineWidth = borderWidth + path.stroke() + } + + let processedImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return processedImage ?? self + } +} diff --git a/lefthook.yml b/lefthook.yml index b22aaa7..82354c0 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -2,10 +2,10 @@ pre-commit: parallel: true commands: lint: - glob: "*.{js,ts,jsx,tsx}" + glob: '*.{js,ts,jsx,tsx}' run: npx eslint {staged_files} types: - glob: "*.{js,ts, jsx, tsx}" + glob: '*.{js,ts, jsx, tsx}' run: npx tsc --noEmit commit-msg: parallel: true diff --git a/src/MapboxNavigation.tsx b/src/MapboxNavigation.tsx index 5332a2d..bf7110d 100644 --- a/src/MapboxNavigation.tsx +++ b/src/MapboxNavigation.tsx @@ -1,5 +1,4 @@ -import * as React from 'react'; - +import React, { useEffect, useState } from 'react'; import type { Permission, TextStyle, ViewStyle } from 'react-native'; import { PermissionsAndroid, @@ -8,14 +7,9 @@ import { Text, View, } from 'react-native'; - import type { MapboxNavigationProps } from './types'; import MapboxNavigationView from './MapboxNavigationViewNativeComponent'; -// import MapboxNavigationNativeComponent, { -// Commands, -// } from './MapboxNavigationViewNativeComponent'; - const permissions: Array = Platform.OS === 'android' && Platform.Version >= 33 ? [ @@ -24,45 +18,34 @@ const permissions: Array = ] : ['android.permission.ACCESS_FINE_LOCATION']; -type MapboxNavigationState = { - prepared: boolean; - error?: string; -}; +const MapboxNavigation: React.FC = (props) => { + const [prepared, setPrepared] = useState(false); + const [error, setError] = useState(); -class MapboxNavigation extends React.Component< - MapboxNavigationProps, - MapboxNavigationState -> { - constructor(props: MapboxNavigationProps) { - super(props); - this.createState(); - } - - createState() { - this.state = { prepared: false }; - } - - componentDidMount(): void { + useEffect(() => { if (Platform.OS === 'android') { - this.requestPermission(); + requestPermission(); } else { - this.setState({ prepared: true }); + setPrepared(true); } - } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - async requestPermission() { + const requestPermission = async () => { try { - let result = await PermissionsAndroid.requestMultiple(permissions); + const result = await PermissionsAndroid.requestMultiple(permissions); type ResultKey = keyof typeof result; + if ( result[permissions[0] as ResultKey] === PermissionsAndroid.RESULTS.GRANTED ) { - this.setState({ prepared: true }); + setPrepared(true); } else { const errorMessage = 'Permission is not granted.'; - this.setState({ error: errorMessage }); + setError(errorMessage); } + if ( permissions.length > 1 && result[permissions[1] as ResultKey] !== @@ -70,70 +53,64 @@ class MapboxNavigation extends React.Component< ) { const errorMessage = 'Notification permission is not granted.'; console.warn(errorMessage); - - this.props.onError?.({ message: errorMessage }); + props.onError?.({ message: errorMessage }); } } catch (e) { - const error = e as Error; - this.setState({ error: error.message }); - console.warn('[Mapbox Navigation] ' + error.message); - this.props.onError?.({ message: error.message }); + const err = e as Error; + setError(err.message); + console.warn('[Mapbox Navigation] ' + err.message); + props.onError?.({ message: err.message }); } - } - - render() { - if (!this.state.prepared) { - const overiteViewStyle: ViewStyle = { - justifyContent: 'center', - alignItems: 'center', - }; - const overiteTextStyle: TextStyle = this.state.error - ? { color: 'red' } - : {}; - return ( - - Loading... - - ); - } - const { - startOrigin, - destination, - style, - distanceUnit = 'imperial', - onArrive, - onLocationChange, - onRouteProgressChange, - onCancelNavigation, - onError, - travelMode, - ...rest - } = this.props; + }; + if (!prepared) { + const overiteViewStyle: ViewStyle = { + justifyContent: 'center', + alignItems: 'center', + }; + const overiteTextStyle: TextStyle = error ? { color: 'red' } : {}; return ( - - onLocationChange?.(event.nativeEvent)} - onRouteProgressChange={(event) => - onRouteProgressChange?.(event.nativeEvent) - } - onError={(event) => onError?.(event.nativeEvent)} - onArrive={(event) => onArrive?.(event.nativeEvent)} - onCancelNavigation={(event) => - onCancelNavigation?.(event.nativeEvent) - } - travelMode={travelMode} - {...rest} - /> + + Loading... ); } -} + + const { + startOrigin, + destination, + style, + distanceUnit = 'imperial', + onArrive, + onLocationChange, + onRouteProgressChange, + onCancelNavigation, + onError, + travelMode, + ...rest + } = props; + + return ( + + onLocationChange?.(event.nativeEvent)} + onRouteProgressChange={(event) => + onRouteProgressChange?.(event.nativeEvent) + } + onError={(event) => onError?.(event.nativeEvent)} + onArrive={(event) => onArrive?.(event.nativeEvent)} + onCancelNavigation={(event) => onCancelNavigation?.(event.nativeEvent)} + travelMode={travelMode} + {...rest} + /> + + ); +}; const styles = StyleSheet.create({ mapbox: {