@jacques_gordon/expo-mapbox-navigation 2.1.1 → 2.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,19 +2,27 @@ package expo.modules.mapboxnavigation
2
2
 
3
3
  import android.annotation.SuppressLint
4
4
  import android.content.Context
5
+ import android.content.res.Resources
5
6
  import android.graphics.Color
7
+ import android.graphics.drawable.GradientDrawable
6
8
  import android.util.Log
7
9
  import android.view.Gravity
8
10
  import android.view.View
9
11
  import android.widget.FrameLayout
12
+ import android.widget.ImageView
10
13
  import android.widget.LinearLayout
11
14
  import android.widget.TextView
12
15
  import com.mapbox.api.directions.v5.models.RouteOptions
13
16
  import com.mapbox.common.location.Location
14
17
  import com.mapbox.geojson.Point
18
+ import com.mapbox.maps.EdgeInsets
19
+ import com.mapbox.maps.ImageHolder
15
20
  import com.mapbox.maps.MapView
21
+ import com.mapbox.maps.plugin.LocationPuck2D
16
22
  import com.mapbox.maps.plugin.animation.camera
17
23
  import com.mapbox.maps.plugin.locationcomponent.location
24
+ import com.mapbox.maps.plugin.locationcomponent.OnIndicatorBearingChangedListener
25
+ import com.mapbox.maps.plugin.locationcomponent.OnIndicatorPositionChangedListener
18
26
  import com.mapbox.navigation.base.TimeFormat
19
27
  import com.mapbox.navigation.base.extensions.applyDefaultNavigationOptions
20
28
  import com.mapbox.navigation.base.formatter.DistanceFormatterOptions
@@ -35,7 +43,6 @@ import com.mapbox.navigation.tripdata.maneuver.api.MapboxManeuverApi
35
43
  import com.mapbox.navigation.tripdata.progress.api.MapboxTripProgressApi
36
44
  import com.mapbox.navigation.tripdata.progress.model.DistanceRemainingFormatter
37
45
  import com.mapbox.navigation.tripdata.progress.model.EstimatedTimeToArrivalFormatter
38
- import com.mapbox.navigation.tripdata.progress.model.PercentDistanceTraveledFormatter
39
46
  import com.mapbox.navigation.tripdata.progress.model.TimeRemainingFormatter
40
47
  import com.mapbox.navigation.tripdata.progress.model.TripProgressUpdateFormatter
41
48
  import com.mapbox.navigation.tripdata.speedlimit.api.MapboxSpeedInfoApi
@@ -58,7 +65,6 @@ import expo.modules.kotlin.AppContext
58
65
  import expo.modules.kotlin.viewevent.EventDispatcher
59
66
  import expo.modules.kotlin.views.ExpoView
60
67
  import java.util.Calendar
61
- import java.util.Date
62
68
  import java.util.Locale
63
69
 
64
70
  private const val TAG = "ExpoMapboxNavigation"
@@ -84,6 +90,12 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
84
90
  private var tvDistance: TextView? = null
85
91
  private var etaBar: LinearLayout? = null
86
92
 
93
+ // ── Side action buttons (mute, overview, recenter) ────────────────────────
94
+ private var btnMute: TextView? = null
95
+ private var btnOverview: TextView? = null
96
+ private var btnRecenter: TextView? = null
97
+ private var sideButtons: LinearLayout? = null
98
+
87
99
  // ── Navigation APIs ───────────────────────────────────────────────────────
88
100
  private lateinit var navigationCamera: NavigationCamera
89
101
  private lateinit var viewportDataSource: MapboxNavigationViewportDataSource
@@ -96,7 +108,36 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
96
108
  private lateinit var routeArrowView: MapboxRouteArrowView
97
109
  private val navigationLocationProvider = NavigationLocationProvider()
98
110
  private var mapboxNavigation: MapboxNavigation? = null
111
+
112
+ // ── State ─────────────────────────────────────────────────────────────────
99
113
  private var isNightMode = false
114
+ private var isMuted = false
115
+ private var isOverviewMode = false
116
+ private var firstLocationReceived = false
117
+
118
+ // ── Pixel density ─────────────────────────────────────────────────────────
119
+ private val dp = context.resources.displayMetrics.density
120
+
121
+ // ── Viewport padding (Waze-style: vehicle at ~30% from bottom) ────────────
122
+ // top=180dp → room for maneuver banner
123
+ // bottom=300dp → pushes vehicle UP toward 30% from bottom (Waze style)
124
+ // Without large bottom padding, vehicle sits at screen center
125
+ private val followingPadding by lazy {
126
+ EdgeInsets(
127
+ 180.0 * dp, // top (maneuver banner height)
128
+ 40.0 * dp, // left
129
+ 300.0 * dp, // bottom — large value pushes vehicle up like Waze
130
+ 40.0 * dp // right
131
+ )
132
+ }
133
+ private val overviewPadding by lazy {
134
+ EdgeInsets(
135
+ 120.0 * dp,
136
+ 40.0 * dp,
137
+ 120.0 * dp,
138
+ 40.0 * dp
139
+ )
140
+ }
100
141
 
101
142
  // ── Props ─────────────────────────────────────────────────────────────────
102
143
  private var coordinates: List<Map<String, Double>> = emptyList()
@@ -120,20 +161,20 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
120
161
  }
121
162
 
122
163
  // ─────────────────────────────────────────────────────────────────────────
123
- // Build UI
164
+ // Build Waze-style UI
124
165
  // ─────────────────────────────────────────────────────────────────────────
125
166
  private fun buildUI() {
126
- val dp = context.resources.displayMetrics.density
127
167
  val root = FrameLayout(context).apply {
128
168
  layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
129
169
  }
130
170
 
171
+ // Full-screen map
131
172
  root.addView(mapView, FrameLayout.LayoutParams(
132
173
  FrameLayout.LayoutParams.MATCH_PARENT,
133
174
  FrameLayout.LayoutParams.MATCH_PARENT
134
175
  ))
135
176
 
136
- // ManeuverView — top (cast to View: extends ConstraintLayout)
177
+ // ── ManeuverView — top banner ──────────────────────────────────────────
137
178
  val mv = MapboxManeuverView(context)
138
179
  root.addView(mv as View, FrameLayout.LayoutParams(
139
180
  FrameLayout.LayoutParams.MATCH_PARENT,
@@ -142,55 +183,97 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
142
183
  mv.visibility = View.INVISIBLE
143
184
  maneuverView = mv
144
185
 
145
- // SpeedInfoView — bottom-left (cast to View)
186
+ // ── SpeedInfoView — bottom-left, above ETA bar ────────────────────────
146
187
  val siv = MapboxSpeedInfoView(context)
147
188
  root.addView(siv as View, FrameLayout.LayoutParams(
148
- FrameLayout.LayoutParams.WRAP_CONTENT,
149
- FrameLayout.LayoutParams.WRAP_CONTENT
189
+ (72 * dp).toInt(),
190
+ (72 * dp).toInt()
150
191
  ).also {
151
192
  it.gravity = Gravity.BOTTOM or Gravity.START
152
- it.setMargins((16 * dp).toInt(), 0, 0, (100 * dp).toInt())
193
+ it.setMargins((12 * dp).toInt(), 0, 0, (88 * dp).toInt())
153
194
  })
154
195
  siv.visibility = View.GONE
155
196
  speedInfoView = siv
156
197
 
157
- // TripProgressView (used by the official Mapbox render method)
158
- val tpv = MapboxTripProgressView(context)
159
- tripProgressView = tpv
198
+ // ── Side action buttons (right side, Waze-style) ──────────────────────
199
+ val sideCol = LinearLayout(context).apply {
200
+ orientation = LinearLayout.VERTICAL
201
+ gravity = Gravity.CENTER_HORIZONTAL
202
+ visibility = View.INVISIBLE
203
+ }
204
+ root.addView(sideCol, FrameLayout.LayoutParams(
205
+ FrameLayout.LayoutParams.WRAP_CONTENT,
206
+ FrameLayout.LayoutParams.WRAP_CONTENT
207
+ ).also {
208
+ it.gravity = Gravity.END or Gravity.BOTTOM
209
+ it.setMargins(0, 0, (12 * dp).toInt(), (96 * dp).toInt())
210
+ })
211
+ sideButtons = sideCol
212
+
213
+ // Mute button
214
+ val muteBtn = makeCircleButton("🔊")
215
+ muteBtn.setOnClickListener { toggleMute() }
216
+ sideCol.addView(muteBtn, circleButtonParams())
217
+ btnMute = muteBtn
160
218
 
161
- // Bottom ETA bar
219
+ sideCol.addView(View(context), LinearLayout.LayoutParams(1, (10 * dp).toInt()))
220
+
221
+ // Overview button
222
+ val overviewBtn = makeCircleButton("⊕")
223
+ overviewBtn.setOnClickListener { toggleOverview() }
224
+ sideCol.addView(overviewBtn, circleButtonParams())
225
+ btnOverview = overviewBtn
226
+
227
+ sideCol.addView(View(context), LinearLayout.LayoutParams(1, (10 * dp).toInt()))
228
+
229
+ // Recenter button (shown only in overview mode)
230
+ val recenterBtn = makeCircleButton("◎")
231
+ recenterBtn.setOnClickListener { recenterCamera() }
232
+ recenterBtn.visibility = View.GONE
233
+ sideCol.addView(recenterBtn, circleButtonParams())
234
+ btnRecenter = recenterBtn
235
+
236
+ // ── ETA bottom bar ─────────────────────────────────────────────────────
162
237
  val bar = LinearLayout(context).apply {
163
238
  orientation = LinearLayout.HORIZONTAL
164
- setBackgroundColor(Color.WHITE)
239
+ setBackgroundColor(Color.parseColor("#1E2433"))
165
240
  elevation = 8 * dp
166
241
  visibility = View.INVISIBLE
167
242
  gravity = Gravity.CENTER_VERTICAL
168
243
  setPadding(
169
- (16 * dp).toInt(), (8 * dp).toInt(),
170
- (16 * dp).toInt(), (8 * dp).toInt()
244
+ (16 * dp).toInt(), (12 * dp).toInt(),
245
+ (16 * dp).toInt(), (12 * dp).toInt()
171
246
  )
172
247
  }
173
248
  root.addView(bar, FrameLayout.LayoutParams(
174
- FrameLayout.LayoutParams.MATCH_PARENT, (80 * dp).toInt()
249
+ FrameLayout.LayoutParams.MATCH_PARENT,
250
+ FrameLayout.LayoutParams.WRAP_CONTENT
175
251
  ).also { it.gravity = Gravity.BOTTOM })
176
252
 
253
+ // ETA arrival time
177
254
  val etaTime = TextView(context).apply {
178
- textSize = 22f
179
- setTextColor(Color.BLACK)
255
+ textSize = 24f
256
+ setTextColor(Color.WHITE)
180
257
  typeface = android.graphics.Typeface.DEFAULT_BOLD
181
258
  }
182
259
  bar.addView(etaTime, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
183
260
  tvEtaTime = etaTime
184
261
 
262
+ // Duration + distance (center)
185
263
  val center = LinearLayout(context).apply {
186
264
  orientation = LinearLayout.VERTICAL
187
265
  gravity = Gravity.CENTER
188
266
  }
189
267
  val dur = TextView(context).apply {
190
- textSize = 16f; setTextColor(Color.DKGRAY); gravity = Gravity.CENTER
268
+ textSize = 18f
269
+ setTextColor(Color.WHITE)
270
+ typeface = android.graphics.Typeface.DEFAULT_BOLD
271
+ gravity = Gravity.CENTER
191
272
  }
192
273
  val dist = TextView(context).apply {
193
- textSize = 13f; setTextColor(Color.GRAY); gravity = Gravity.CENTER
274
+ textSize = 13f
275
+ setTextColor(Color.parseColor("#AAAAAA"))
276
+ gravity = Gravity.CENTER
194
277
  }
195
278
  center.addView(dur)
196
279
  center.addView(dist)
@@ -198,27 +281,55 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
198
281
  tvDuration = dur
199
282
  tvDistance = dist
200
283
 
284
+ // Cancel button
201
285
  val cancelBtn = TextView(context).apply {
202
- text = "✕"; textSize = 22f; setTextColor(Color.DKGRAY)
286
+ text = "✕"
287
+ textSize = 22f
288
+ setTextColor(Color.parseColor("#AAAAAA"))
203
289
  gravity = Gravity.END or Gravity.CENTER_VERTICAL
204
290
  setOnClickListener { cancelNavigation() }
205
291
  }
206
292
  bar.addView(cancelBtn, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
207
293
  etaBar = bar
208
294
 
295
+ // TripProgressView — 1×1 hidden (required for render() API)
296
+ val tpv = MapboxTripProgressView(context)
297
+ root.addView(tpv as View, FrameLayout.LayoutParams(1, 1))
298
+ tpv.visibility = View.GONE
299
+ tripProgressView = tpv
300
+
209
301
  addView(root)
210
302
  }
211
303
 
304
+ // Helper: creates a white circle button (Waze style)
305
+ private fun makeCircleButton(text: String): TextView {
306
+ return TextView(context).apply {
307
+ this.text = text
308
+ textSize = 20f
309
+ gravity = Gravity.CENTER
310
+ setTextColor(Color.BLACK)
311
+ background = GradientDrawable().apply {
312
+ shape = GradientDrawable.OVAL
313
+ setColor(Color.WHITE)
314
+ // Drop shadow via elevation
315
+ }
316
+ elevation = 6 * dp
317
+ }
318
+ }
319
+
320
+ private fun circleButtonParams(): LinearLayout.LayoutParams {
321
+ val size = (52 * dp).toInt()
322
+ return LinearLayout.LayoutParams(size, size)
323
+ }
324
+
212
325
  // ─────────────────────────────────────────────────────────────────────────
213
- // Init APIs — confirmed from official Mapbox examples
326
+ // Init APIs
214
327
  // ─────────────────────────────────────────────────────────────────────────
215
328
  private fun initAPIs() {
216
329
  val distanceFormatterOptions = DistanceFormatterOptions.Builder(context).build()
217
330
 
218
331
  maneuverApi = MapboxManeuverApi(MapboxDistanceFormatter(distanceFormatterOptions))
219
332
 
220
- // TripProgressApi — from ShowTripProgressActivity official example
221
- // PercentDistanceTraveledFormatter is optional — omit if causing issues
222
333
  tripProgressApi = MapboxTripProgressApi(
223
334
  TripProgressUpdateFormatter.Builder(context)
224
335
  .distanceRemainingFormatter(DistanceRemainingFormatter(distanceFormatterOptions))
@@ -252,16 +363,35 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
252
363
  mapView.mapboxMap.loadStyle(mapStyle ?: getAutoStyle()) { style ->
253
364
  routeLineView.initializeLayers(style)
254
365
 
366
+ // Camera viewport
255
367
  viewportDataSource = MapboxNavigationViewportDataSource(mapView.mapboxMap)
368
+ viewportDataSource.followingPadding = followingPadding
369
+ viewportDataSource.overviewPadding = overviewPadding
370
+
256
371
  navigationCamera = NavigationCamera(
257
372
  mapView.mapboxMap,
258
373
  mapView.camera,
259
374
  viewportDataSource
260
375
  )
261
376
 
377
+ // ── Location puck — Waze-style arrow ─────────────────────────────
378
+ // FIX: use mapbox_navigation_puck_icon (built-in arrow)
379
+ // + PuckBearing.COURSE to orient it in direction of travel
262
380
  mapView.location.apply {
263
381
  setLocationProvider(navigationLocationProvider)
264
- enabled = true
382
+ updateSettings {
383
+ // Built-in navigation arrow (chevron) from Nav SDK
384
+ locationPuck = LocationPuck2D(
385
+ bearingImage = ImageHolder.from(
386
+ com.mapbox.navigation.R.drawable.mapbox_navigation_puck_icon
387
+ )
388
+ )
389
+ // COURSE = direction of movement (not compass heading)
390
+ puckBearingEnabled = true
391
+ enabled = true
392
+ }
393
+ // Must set puckBearing separately — confirmed from migration guide
394
+ puckBearing = com.mapbox.maps.plugin.PuckBearing.COURSE
265
395
  }
266
396
 
267
397
  registerObservers()
@@ -285,6 +415,8 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
285
415
  }
286
416
  viewportDataSource.onRouteChanged(result.navigationRoutes.first())
287
417
  viewportDataSource.evaluate()
418
+ isOverviewMode = false
419
+ safeCameraFollowing()
288
420
  showUI()
289
421
  onRoutesReady(mapOf(
290
422
  "routeCount" to result.navigationRoutes.size,
@@ -310,21 +442,16 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
310
442
  viewportDataSource.onRouteProgressChanged(routeProgress)
311
443
  viewportDataSource.evaluate()
312
444
 
313
- // Route arrow
445
+ // Maneuver arrow on map
314
446
  mapView.mapboxMap.style?.let { style ->
315
447
  val arrowResult = routeArrowApi.addUpcomingManeuverArrow(routeProgress)
316
448
  routeArrowView.renderManeuverUpdate(style, arrowResult)
317
449
  }
318
450
 
319
- // ── Maneuver — fold() with explicit Unit returns on both branches ──
320
- // Fix for error: "Argument type mismatch: Unit? but R & Any expected"
321
- // Solution: use explicit return type annotation on the success lambda
451
+ // Maneuver banner
322
452
  val maneuvers = maneuverApi.getManeuvers(routeProgress)
323
453
  maneuvers.fold(
324
- { error ->
325
- Log.w(TAG, "Maneuver error: ${error.errorMessage}")
326
- Unit
327
- },
454
+ { error -> Log.w(TAG, "Maneuver error: ${error.errorMessage}"); Unit },
328
455
  { _ ->
329
456
  maneuverView?.visibility = View.VISIBLE
330
457
  maneuverView?.renderManeuvers(maneuvers)
@@ -332,13 +459,7 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
332
459
  }
333
460
  )
334
461
 
335
- // ── Trip progress — use getTripProgress exactly as official example ──
336
- // render() to the MapboxTripProgressView (official pattern)
337
- // For our custom ETA bar, use the raw fields from TripProgressUpdateValue:
338
- // .estimatedTimeToArrival — Long (Unix timestamp ms of arrival)
339
- // .currentLegTimeRemaining — Double (seconds remaining for current leg)
340
- // .totalTimeRemaining — Double (seconds remaining total)
341
- // .distanceRemaining — Double (metres remaining)
462
+ // Trip progress
342
463
  val tripProgress = tripProgressApi.getTripProgress(routeProgress)
343
464
  tripProgressView?.render(tripProgress)
344
465
  updateEtaBar(
@@ -363,14 +484,22 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
363
484
  override fun onNewRawLocation(rawLocation: Location) {}
364
485
  override fun onNewLocationMatcherResult(result: LocationMatcherResult) {
365
486
  val loc = result.enhancedLocation
487
+
366
488
  navigationLocationProvider.changePosition(
367
489
  location = loc,
368
490
  keyPoints = result.keyPoints,
369
491
  )
492
+
370
493
  viewportDataSource.onLocationChanged(loc)
371
494
  viewportDataSource.evaluate()
372
495
 
373
- // Speed limit
496
+ // First GPS fix → enter following mode
497
+ if (!firstLocationReceived) {
498
+ firstLocationReceived = true
499
+ safeCameraFollowing()
500
+ }
501
+
502
+ // Speed limit display
374
503
  val fmtOptions = DistanceFormatterOptions.Builder(context).build()
375
504
  val speedInfo = speedInfoApi.updatePostedAndCurrentSpeed(result, fmtOptions)
376
505
  if (speedInfo != null) {
@@ -386,17 +515,51 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
386
515
  }
387
516
 
388
517
  // ─────────────────────────────────────────────────────────────────────────
389
- // ETA bar — using confirmed TripProgressUpdateValue fields
390
- // estimatedTimeToArrival: Long — Unix timestamp (ms) of expected arrival
391
- // totalTimeRemaining: Double — seconds remaining
392
- // distanceRemaining: Double metres remaining
518
+ // Button actions
519
+ // ─────────────────────────────────────────────────────────────────────────
520
+
521
+ // Toggle mute voice guidance
522
+ private fun toggleMute() {
523
+ isMuted = !isMuted
524
+ btnMute?.text = if (isMuted) "🔇" else "🔊"
525
+ btnMute?.setTextColor(if (isMuted) Color.parseColor("#FF4444") else Color.BLACK)
526
+ // Actual audio muting handled by the voice instruction observer in the app
527
+ // We expose the state via event so the RN layer can mute TTS
528
+ onNavigationCancelled(mapOf("type" to "mute", "muted" to isMuted))
529
+ }
530
+
531
+ // Toggle overview / following mode
532
+ private fun toggleOverview() {
533
+ if (isOverviewMode) {
534
+ recenterCamera()
535
+ } else {
536
+ isOverviewMode = true
537
+ btnOverview?.text = "◉"
538
+ btnRecenter?.visibility = View.VISIBLE
539
+ try {
540
+ navigationCamera.requestNavigationCameraToOverview()
541
+ } catch (e: Exception) {
542
+ Log.e(TAG, "Camera overview error: ${e.message}")
543
+ }
544
+ }
545
+ }
546
+
547
+ // Recenter camera to vehicle (following mode)
548
+ private fun recenterCamera() {
549
+ isOverviewMode = false
550
+ btnOverview?.text = "⊕"
551
+ btnRecenter?.visibility = View.GONE
552
+ safeCameraFollowing()
553
+ }
554
+
555
+ // ─────────────────────────────────────────────────────────────────────────
556
+ // ETA bar
393
557
  // ─────────────────────────────────────────────────────────────────────────
394
558
  private fun updateEtaBar(
395
559
  estimatedTimeToArrivalMs: Long,
396
560
  totalTimeRemainingSec: Double,
397
561
  distanceRemainingMetres: Double
398
562
  ) {
399
- // Arrival clock time from Unix timestamp
400
563
  val arrivalCal = Calendar.getInstance().apply {
401
564
  timeInMillis = estimatedTimeToArrivalMs
402
565
  }
@@ -406,14 +569,12 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
406
569
  arrivalCal.get(Calendar.MINUTE)
407
570
  )
408
571
 
409
- // Duration remaining
410
572
  val totalMin = (totalTimeRemainingSec / 60).toInt()
411
573
  tvDuration?.text = if (totalMin >= 60)
412
574
  "${totalMin / 60}h ${totalMin % 60}min"
413
575
  else
414
576
  "${totalMin} min"
415
577
 
416
- // Distance remaining
417
578
  tvDistance?.text = if (distanceRemainingMetres >= 1000)
418
579
  String.format("%.1f km", distanceRemainingMetres / 1000.0)
419
580
  else
@@ -444,11 +605,21 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
444
605
  }
445
606
  }
446
607
  }
447
- etaBar?.setBackgroundColor(if (shouldBeNight) Color.parseColor("#1E2433") else Color.WHITE)
448
- val tc = if (shouldBeNight) Color.WHITE else Color.BLACK
449
- tvEtaTime?.setTextColor(tc)
450
- tvDuration?.setTextColor(if (shouldBeNight) Color.LTGRAY else Color.DKGRAY)
451
- tvDistance?.setTextColor(Color.GRAY)
608
+ }
609
+
610
+ // ─────────────────────────────────────────────────────────────────────────
611
+ // Camera following issue #43 safe wrapper
612
+ // ─────────────────────────────────────────────────────────────────────────
613
+ private fun safeCameraFollowing() {
614
+ try {
615
+ navigationCamera.requestNavigationCameraToFollowing(
616
+ stateTransitionOptions = NavigationCameraTransitionOptions.Builder()
617
+ .maxDuration(0)
618
+ .build()
619
+ )
620
+ } catch (e: Exception) {
621
+ Log.e(TAG, "Camera following error: ${e.message}")
622
+ }
452
623
  }
453
624
 
454
625
  // ─────────────────────────────────────────────────────────────────────────
@@ -457,6 +628,8 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
457
628
  private fun cancelNavigation() {
458
629
  mapboxNavigation?.setNavigationRoutes(listOf())
459
630
  mapboxNavigation?.stopTripSession()
631
+ firstLocationReceived = false
632
+ isOverviewMode = false
460
633
  hideUI()
461
634
  onNavigationCancelled(mapOf<String, Any>())
462
635
  }
@@ -464,27 +637,19 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
464
637
  private fun showUI() {
465
638
  maneuverView?.visibility = View.VISIBLE
466
639
  etaBar?.visibility = View.VISIBLE
640
+ sideButtons?.visibility = View.VISIBLE
467
641
  }
468
642
 
469
643
  private fun hideUI() {
470
644
  maneuverView?.visibility = View.INVISIBLE
471
645
  speedInfoView?.visibility = View.GONE
472
646
  etaBar?.visibility = View.INVISIBLE
647
+ sideButtons?.visibility = View.INVISIBLE
473
648
  }
474
649
 
475
- // Issue #43
476
- private fun safeCameraFollowing() {
477
- try {
478
- navigationCamera.requestNavigationCameraToFollowing(
479
- stateTransitionOptions = NavigationCameraTransitionOptions.Builder()
480
- .maxDuration(0).build()
481
- )
482
- } catch (e: Exception) {
483
- Log.e(TAG, "Camera error: ${e.message}")
484
- }
485
- }
486
-
487
- // Issue #31
650
+ // ─────────────────────────────────────────────────────────────────────────
651
+ // Issue #31 — Voice units
652
+ // ─────────────────────────────────────────────────────────────────────────
488
653
  private fun resolveVoiceUnits(): String {
489
654
  return when (voiceUnits?.lowercase()) {
490
655
  "metric" -> "metric"
@@ -496,57 +661,83 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
496
661
  }
497
662
  }
498
663
 
664
+ // ─────────────────────────────────────────────────────────────────────────
499
665
  // Route request
666
+ // ─────────────────────────────────────────────────────────────────────────
500
667
  @SuppressLint("MissingPermission")
501
668
  private fun fetchRoutes() {
502
669
  val nav = mapboxNavigation ?: return
503
670
  if (coordinates.size < 2) return
504
- val points = coordinates.map { Point.fromLngLat(it["longitude"] ?: 0.0, it["latitude"] ?: 0.0) }
671
+
672
+ val points = coordinates.map {
673
+ Point.fromLngLat(it["longitude"] ?: 0.0, it["latitude"] ?: 0.0)
674
+ }
505
675
  val locale = language?.let { Locale.forLanguageTag(it) } ?: Locale.getDefault()
676
+
506
677
  val builder = RouteOptions.builder()
507
678
  .applyDefaultNavigationOptions()
508
679
  .language(locale.toLanguageTag())
509
680
  .voiceUnits(resolveVoiceUnits())
510
681
  .coordinatesList(points)
511
- .annotations("maxspeed,congestion,duration")
682
+ .annotations("maxspeed,congestion,duration,speed")
683
+
512
684
  waypointIndices?.let { builder.waypointIndicesList(it) }
513
- navigationProfile?.let { builder.profile(if (it.startsWith("mapbox/")) it else "mapbox/$it") }
514
- excludeTypes?.takeIf { it.isNotEmpty() }?.let { builder.exclude(it.joinToString(",")) }
685
+ navigationProfile?.let {
686
+ builder.profile(if (it.startsWith("mapbox/")) it else "mapbox/$it")
687
+ }
688
+ excludeTypes?.takeIf { it.isNotEmpty() }?.let {
689
+ builder.exclude(it.joinToString(","))
690
+ }
515
691
  maxHeight?.let { builder.maxHeight(it) }
516
692
  maxWidth?.let { builder.maxWidth(it) }
693
+
517
694
  nav.requestRoutes(builder.build(), object : NavigationRouterCallback {
518
- override fun onRoutesReady(routes: List<NavigationRoute>, @RouterOrigin routerOrigin: String) {
519
- if (routes.isEmpty()) { onRoutesFailed(mapOf("message" to "No routes returned")); return }
695
+ override fun onRoutesReady(
696
+ routes: List<NavigationRoute>,
697
+ @RouterOrigin routerOrigin: String
698
+ ) {
699
+ if (routes.isEmpty()) {
700
+ onRoutesFailed(mapOf("message" to "No routes returned"))
701
+ return
702
+ }
520
703
  nav.setNavigationRoutes(routes)
521
704
  nav.startTripSession()
522
- safeCameraFollowing()
523
705
  }
706
+
524
707
  override fun onFailure(reasons: List<RouterFailure>, routeOptions: RouteOptions) {
525
708
  val msg = reasons.firstOrNull()?.message ?: "Unknown error"
526
709
  Log.e(TAG, "Route failed: $msg")
527
710
  onRoutesFailed(mapOf("message" to msg))
528
711
  }
529
- override fun onCanceled(routeOptions: RouteOptions, @RouterOrigin routerOrigin: String) {
712
+
713
+ override fun onCanceled(
714
+ routeOptions: RouteOptions,
715
+ @RouterOrigin routerOrigin: String
716
+ ) {
530
717
  Log.d(TAG, "Route cancelled")
531
718
  }
532
719
  })
533
720
  }
534
721
 
535
- // Prop setters
536
- fun setCoordinates(coords: List<Map<String, Double>>) { coordinates = coords; if (coords.size >= 2) fetchRoutes() }
537
- fun setWaypointIndices(indices: List<Int>?) { waypointIndices = indices }
538
- fun setLanguage(lang: String?) { language = lang }
539
- fun setVoiceUnits(units: String?) { voiceUnits = units }
540
- fun setNavigationProfile(profile: String?) { navigationProfile = profile }
541
- fun setExcludeTypes(types: List<String>?) { excludeTypes = types }
542
- fun setMapStyle(style: String?) { mapStyle = style }
543
- fun setMute(shouldMute: Boolean) { mute = shouldMute }
544
- fun setMaxHeight(height: Double?) { maxHeight = height }
545
- fun setMaxWidth(width: Double?) { maxWidth = width }
546
- fun setUseMapMatching(use: Boolean) { useMapMatching = use }
547
- fun setCustomRasterTileUrl(url: String?) { customRasterTileUrl = url }
548
- fun setCustomRasterAboveLayerId(layerId: String?) { customRasterAboveLayerId = layerId }
549
-
722
+ // ── Prop setters ──────────────────────────────────────────────────────────
723
+ fun setCoordinates(coords: List<Map<String, Double>>) {
724
+ coordinates = coords
725
+ if (coords.size >= 2) fetchRoutes()
726
+ }
727
+ fun setWaypointIndices(i: List<Int>?) { waypointIndices = i }
728
+ fun setLanguage(l: String?) { language = l }
729
+ fun setVoiceUnits(u: String?) { voiceUnits = u }
730
+ fun setNavigationProfile(p: String?) { navigationProfile = p }
731
+ fun setExcludeTypes(t: List<String>?) { excludeTypes = t }
732
+ fun setMapStyle(s: String?) { mapStyle = s }
733
+ fun setMute(m: Boolean) { mute = m; if (m != isMuted) toggleMute() }
734
+ fun setMaxHeight(h: Double?) { maxHeight = h }
735
+ fun setMaxWidth(w: Double?) { maxWidth = w }
736
+ fun setUseMapMatching(u: Boolean) { useMapMatching = u }
737
+ fun setCustomRasterTileUrl(u: String?) { customRasterTileUrl = u }
738
+ fun setCustomRasterAboveLayerId(l: String?) { customRasterAboveLayerId = l }
739
+
740
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
550
741
  override fun onDetachedFromWindow() {
551
742
  super.onDetachedFromWindow()
552
743
  maneuverApi.cancel()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacques_gordon/expo-mapbox-navigation",
3
- "version": "2.1.1",
3
+ "version": "2.1.3",
4
4
  "description": "Expo module for Mapbox Navigation SDK with 16KB page size support, NDK27, and Mapbox Maps v11.11.0+",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",