@jacques_gordon/expo-mapbox-navigation 2.1.0 → 2.1.2

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,6 +2,7 @@ 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
6
7
  import android.util.Log
7
8
  import android.view.Gravity
@@ -12,6 +13,7 @@ import android.widget.TextView
12
13
  import com.mapbox.api.directions.v5.models.RouteOptions
13
14
  import com.mapbox.common.location.Location
14
15
  import com.mapbox.geojson.Point
16
+ import com.mapbox.maps.EdgeInsets
15
17
  import com.mapbox.maps.MapView
16
18
  import com.mapbox.maps.plugin.animation.camera
17
19
  import com.mapbox.maps.plugin.locationcomponent.location
@@ -35,7 +37,6 @@ import com.mapbox.navigation.tripdata.maneuver.api.MapboxManeuverApi
35
37
  import com.mapbox.navigation.tripdata.progress.api.MapboxTripProgressApi
36
38
  import com.mapbox.navigation.tripdata.progress.model.DistanceRemainingFormatter
37
39
  import com.mapbox.navigation.tripdata.progress.model.EstimatedTimeToArrivalFormatter
38
- import com.mapbox.navigation.tripdata.progress.model.PercentDistanceTraveledFormatter
39
40
  import com.mapbox.navigation.tripdata.progress.model.TimeRemainingFormatter
40
41
  import com.mapbox.navigation.tripdata.progress.model.TripProgressUpdateFormatter
41
42
  import com.mapbox.navigation.tripdata.speedlimit.api.MapboxSpeedInfoApi
@@ -65,7 +66,7 @@ private const val TAG = "ExpoMapboxNavigation"
65
66
  class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
66
67
  ExpoView(context, appContext) {
67
68
 
68
- // ── EventDispatchers (correct Expo Modules API for View events) ───────────
69
+ // ── EventDispatchers ──────────────────────────────────────────────────────
69
70
  private val onRouteProgressChanged by EventDispatcher()
70
71
  private val onRoutesReady by EventDispatcher()
71
72
  private val onNavigationFinished by EventDispatcher()
@@ -83,7 +84,7 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
83
84
  private var tvDistance: TextView? = null
84
85
  private var etaBar: LinearLayout? = null
85
86
 
86
- // ── Navigation APIs (from official TurnByTurnExperienceActivity) ──────────
87
+ // ── Navigation APIs ───────────────────────────────────────────────────────
87
88
  private lateinit var navigationCamera: NavigationCamera
88
89
  private lateinit var viewportDataSource: MapboxNavigationViewportDataSource
89
90
  private lateinit var routeLineApi: MapboxRouteLineApi
@@ -95,10 +96,31 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
95
96
  private lateinit var routeArrowView: MapboxRouteArrowView
96
97
  private val navigationLocationProvider = NavigationLocationProvider()
97
98
  private var mapboxNavigation: MapboxNavigation? = null
98
-
99
- // ── Night mode ────────────────────────────────────────────────────────────
100
99
  private var isNightMode = false
101
100
 
101
+ // ── FIX: Track whether first location has been received ───────────────────
102
+ // Without this, camera never enters following mode on first GPS fix
103
+ private var firstLocationReceived = false
104
+
105
+ // ── Viewport padding (from official TurnByTurnExperienceActivity) ─────────
106
+ private val pixelDensity = Resources.getSystem().displayMetrics.density
107
+ private val followingPadding by lazy {
108
+ EdgeInsets(
109
+ 180.0 * pixelDensity, // top — room for maneuver banner
110
+ 40.0 * pixelDensity,
111
+ 150.0 * pixelDensity, // bottom — room for ETA bar
112
+ 40.0 * pixelDensity
113
+ )
114
+ }
115
+ private val overviewPadding by lazy {
116
+ EdgeInsets(
117
+ 140.0 * pixelDensity,
118
+ 40.0 * pixelDensity,
119
+ 120.0 * pixelDensity,
120
+ 40.0 * pixelDensity
121
+ )
122
+ }
123
+
102
124
  // ── Props ─────────────────────────────────────────────────────────────────
103
125
  private var coordinates: List<Map<String, Double>> = emptyList()
104
126
  private var waypointIndices: List<Int>? = null
@@ -121,7 +143,7 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
121
143
  }
122
144
 
123
145
  // ─────────────────────────────────────────────────────────────────────────
124
- // 1. Build programmatic UI
146
+ // Build UI
125
147
  // ─────────────────────────────────────────────────────────────────────────
126
148
  private fun buildUI() {
127
149
  val dp = context.resources.displayMetrics.density
@@ -129,108 +151,123 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
129
151
  layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
130
152
  }
131
153
 
132
- // Full-screen MapView
154
+ // Full screen map
133
155
  root.addView(mapView, FrameLayout.LayoutParams(
134
156
  FrameLayout.LayoutParams.MATCH_PARENT,
135
157
  FrameLayout.LayoutParams.MATCH_PARENT
136
158
  ))
137
159
 
138
- // ManeuverView — top banner
139
- // NOTE: MapboxManeuverView extends ConstraintLayout, cast to View for addView()
160
+ // ── ManeuverView — top banner ──────────────────────────────────────────
161
+ // extends ConstraintLayout → must cast to View for addView()
140
162
  val mv = MapboxManeuverView(context)
141
- val mvParams = FrameLayout.LayoutParams(
163
+ root.addView(mv as View, FrameLayout.LayoutParams(
142
164
  FrameLayout.LayoutParams.MATCH_PARENT,
143
165
  FrameLayout.LayoutParams.WRAP_CONTENT
144
- )
145
- mvParams.gravity = Gravity.TOP
146
- mv.visibility = View.INVISIBLE // matches official example (INVISIBLE not GONE)
147
- root.addView(mv as View, mvParams)
166
+ ).also { it.gravity = Gravity.TOP })
167
+ mv.visibility = View.INVISIBLE
148
168
  maneuverView = mv
149
169
 
150
- // SpeedInfoView — bottom-left
170
+ // ── SpeedInfoView — bottom-left ────────────────────────────────────────
171
+ // Shows posted speed limit + current vehicle speed
151
172
  val siv = MapboxSpeedInfoView(context)
152
- val sivParams = FrameLayout.LayoutParams(
173
+ root.addView(siv as View, FrameLayout.LayoutParams(
153
174
  FrameLayout.LayoutParams.WRAP_CONTENT,
154
175
  FrameLayout.LayoutParams.WRAP_CONTENT
155
- )
156
- sivParams.gravity = Gravity.BOTTOM or Gravity.START
157
- sivParams.setMargins((16 * dp).toInt(), 0, 0, (100 * dp).toInt())
176
+ ).also {
177
+ it.gravity = Gravity.BOTTOM or Gravity.START
178
+ // Position above the ETA bar (80dp) + margin
179
+ it.setMargins((16 * dp).toInt(), 0, 0, (96 * dp).toInt())
180
+ })
158
181
  siv.visibility = View.GONE
159
- root.addView(siv as View, sivParams)
160
182
  speedInfoView = siv
161
183
 
162
- // TripProgressView embedded in bottom bar
163
- val tpv = MapboxTripProgressView(context)
164
- tripProgressView = tpv
165
-
166
- // ETA bottom bar
184
+ // ── ETA bottom bar ─────────────────────────────────────────────────────
167
185
  val bar = LinearLayout(context).apply {
168
186
  orientation = LinearLayout.HORIZONTAL
169
- setBackgroundColor(Color.WHITE)
187
+ setBackgroundColor(Color.parseColor("#1E2433")) // dark nav style
170
188
  elevation = 8 * dp
171
189
  visibility = View.INVISIBLE
172
190
  gravity = Gravity.CENTER_VERTICAL
173
191
  setPadding(
174
- (16 * dp).toInt(), (8 * dp).toInt(),
175
- (16 * dp).toInt(), (8 * dp).toInt()
192
+ (16 * dp).toInt(), (12 * dp).toInt(),
193
+ (16 * dp).toInt(), (12 * dp).toInt()
176
194
  )
177
195
  }
178
- val barParams = FrameLayout.LayoutParams(
196
+ root.addView(bar, FrameLayout.LayoutParams(
179
197
  FrameLayout.LayoutParams.MATCH_PARENT,
180
- (80 * dp).toInt()
181
- )
182
- barParams.gravity = Gravity.BOTTOM
198
+ FrameLayout.LayoutParams.WRAP_CONTENT
199
+ ).also { it.gravity = Gravity.BOTTOM })
183
200
 
201
+ // ETA arrival time
184
202
  val etaTime = TextView(context).apply {
185
- textSize = 22f
186
- setTextColor(Color.BLACK)
203
+ textSize = 24f
204
+ setTextColor(Color.WHITE)
187
205
  typeface = android.graphics.Typeface.DEFAULT_BOLD
188
206
  }
189
- bar.addView(etaTime, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
207
+ bar.addView(etaTime, LinearLayout.LayoutParams(
208
+ 0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f
209
+ ))
190
210
  tvEtaTime = etaTime
191
211
 
212
+ // Duration + distance (center)
192
213
  val center = LinearLayout(context).apply {
193
214
  orientation = LinearLayout.VERTICAL
194
215
  gravity = Gravity.CENTER
195
216
  }
196
- val dur = TextView(context).apply { textSize = 16f; setTextColor(Color.DKGRAY); gravity = Gravity.CENTER }
197
- val dist = TextView(context).apply { textSize = 13f; setTextColor(Color.GRAY); gravity = Gravity.CENTER }
217
+ val dur = TextView(context).apply {
218
+ textSize = 18f
219
+ setTextColor(Color.WHITE)
220
+ typeface = android.graphics.Typeface.DEFAULT_BOLD
221
+ gravity = Gravity.CENTER
222
+ }
223
+ val dist = TextView(context).apply {
224
+ textSize = 13f
225
+ setTextColor(Color.parseColor("#AAAAAA"))
226
+ gravity = Gravity.CENTER
227
+ }
198
228
  center.addView(dur)
199
229
  center.addView(dist)
200
- bar.addView(center, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 2f))
230
+ bar.addView(center, LinearLayout.LayoutParams(
231
+ 0, LinearLayout.LayoutParams.WRAP_CONTENT, 2f
232
+ ))
201
233
  tvDuration = dur
202
234
  tvDistance = dist
203
235
 
236
+ // Cancel button
204
237
  val cancelBtn = TextView(context).apply {
205
238
  text = "✕"
206
239
  textSize = 22f
207
- setTextColor(Color.DKGRAY)
240
+ setTextColor(Color.parseColor("#AAAAAA"))
208
241
  gravity = Gravity.END or Gravity.CENTER_VERTICAL
209
242
  setOnClickListener { cancelNavigation() }
210
243
  }
211
- bar.addView(cancelBtn, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
244
+ bar.addView(cancelBtn, LinearLayout.LayoutParams(
245
+ 0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f
246
+ ))
212
247
  etaBar = bar
213
- root.addView(bar, barParams)
248
+
249
+ // ── TripProgressView — hidden native view (used for render() API) ──────
250
+ // FIX: was created but never added to root — now added but invisible
251
+ val tpv = MapboxTripProgressView(context)
252
+ root.addView(tpv as View, FrameLayout.LayoutParams(1, 1)) // 1x1 hidden
253
+ tpv.visibility = View.GONE
254
+ tripProgressView = tpv
214
255
 
215
256
  addView(root)
216
257
  }
217
258
 
218
259
  // ─────────────────────────────────────────────────────────────────────────
219
- // 2. Init APIs (from official example — exact same pattern)
260
+ // Init APIs
220
261
  // ─────────────────────────────────────────────────────────────────────────
221
262
  private fun initAPIs() {
222
263
  val distanceFormatterOptions = DistanceFormatterOptions.Builder(context).build()
223
264
 
224
- // EXACT same instantiation as TurnByTurnExperienceActivity
225
- maneuverApi = MapboxManeuverApi(
226
- MapboxDistanceFormatter(distanceFormatterOptions)
227
- )
265
+ maneuverApi = MapboxManeuverApi(MapboxDistanceFormatter(distanceFormatterOptions))
228
266
 
229
267
  tripProgressApi = MapboxTripProgressApi(
230
268
  TripProgressUpdateFormatter.Builder(context)
231
269
  .distanceRemainingFormatter(DistanceRemainingFormatter(distanceFormatterOptions))
232
270
  .timeRemainingFormatter(TimeRemainingFormatter(context))
233
- .percentRouteTraveledFormatter(PercentDistanceTraveledFormatter())
234
271
  .estimatedTimeToArrivalFormatter(
235
272
  EstimatedTimeToArrivalFormatter(context, TimeFormat.NONE_SPECIFIED)
236
273
  )
@@ -239,32 +276,35 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
239
276
 
240
277
  speedInfoApi = MapboxSpeedInfoApi()
241
278
 
242
- val routeLineViewOptions = MapboxRouteLineViewOptions.Builder(context)
243
- .routeLineBelowLayerId("road-label-navigation")
244
- .build()
245
279
  routeLineApi = MapboxRouteLineApi(MapboxRouteLineApiOptions.Builder().build())
246
- routeLineView = MapboxRouteLineView(routeLineViewOptions)
280
+ routeLineView = MapboxRouteLineView(
281
+ MapboxRouteLineViewOptions.Builder(context)
282
+ .routeLineBelowLayerId("road-label-navigation")
283
+ .build()
284
+ )
247
285
 
248
- val routeArrowOptions = RouteArrowOptions.Builder(context).build()
249
- routeArrowView = MapboxRouteArrowView(routeArrowOptions)
286
+ routeArrowView = MapboxRouteArrowView(RouteArrowOptions.Builder(context).build())
250
287
  }
251
288
 
252
289
  // ─────────────────────────────────────────────────────────────────────────
253
- // 3. Setup Navigation
290
+ // Setup Navigation
254
291
  // ─────────────────────────────────────────────────────────────────────────
255
292
  private fun setupNavigation() {
256
293
  mapboxNavigation = MapboxNavigationProvider.create(
257
294
  NavigationOptions.Builder(context).build()
258
295
  )
259
296
 
260
- val style = mapStyle ?: getAutoStyle()
261
- mapView.mapboxMap.loadStyle(style) { mapStyle ->
262
-
263
- // Route line layers must be initialized first
264
- routeLineView.initializeLayers(mapStyle)
297
+ mapView.mapboxMap.loadStyle(mapStyle ?: getAutoStyle()) { style ->
298
+ routeLineView.initializeLayers(style)
265
299
 
266
- // Camera setup EXACT same as official example
300
+ // Camera viewport data source
267
301
  viewportDataSource = MapboxNavigationViewportDataSource(mapView.mapboxMap)
302
+
303
+ // FIX: Set padding BEFORE creating NavigationCamera
304
+ // Without this, the camera doesn't account for the maneuver banner and ETA bar
305
+ viewportDataSource.followingPadding = followingPadding
306
+ viewportDataSource.overviewPadding = overviewPadding
307
+
268
308
  navigationCamera = NavigationCamera(
269
309
  mapView.mapboxMap,
270
310
  mapView.camera,
@@ -282,7 +322,7 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
282
322
  }
283
323
 
284
324
  // ─────────────────────────────────────────────────────────────────────────
285
- // 4. Observers — based 100% on official TurnByTurnExperienceActivity
325
+ // Observers
286
326
  // ─────────────────────────────────────────────────────────────────────────
287
327
  private fun registerObservers() {
288
328
  val nav = mapboxNavigation ?: return
@@ -291,19 +331,18 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
291
331
  nav.registerRoutesObserver(object : RoutesObserver {
292
332
  override fun onRoutesChanged(result: RoutesUpdatedResult) {
293
333
  if (result.navigationRoutes.isNotEmpty()) {
294
- // Draw route line
295
334
  routeLineApi.setNavigationRoutes(result.navigationRoutes) { value ->
296
335
  mapView.mapboxMap.style?.apply {
297
336
  routeLineView.renderRouteDrawData(this, value)
298
337
  }
299
338
  }
300
- // Update camera viewport
301
339
  viewportDataSource.onRouteChanged(result.navigationRoutes.first())
302
340
  viewportDataSource.evaluate()
303
341
 
304
- // Show navigation UI
305
- maneuverView?.visibility = View.VISIBLE
306
- etaBar?.visibility = View.VISIBLE
342
+ // FIX: transition to following mode when route is set
343
+ // This triggers the camera to zoom/pitch/center correctly
344
+ safeCameraFollowing()
345
+ showUI()
307
346
 
308
347
  onRoutesReady(mapOf(
309
348
  "routeCount" to result.navigationRoutes.size,
@@ -311,9 +350,7 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
311
350
  "durationSeconds" to (result.navigationRoutes.first().directionsRoute.duration() ?: 0.0)
312
351
  ))
313
352
  } else {
314
- // Clear route line
315
- val style = mapView.mapboxMap.style
316
- if (style != null) {
353
+ mapView.mapboxMap.style?.let { style ->
317
354
  routeLineApi.clearRouteLine { value ->
318
355
  routeLineView.renderClearRouteLineValue(style, value)
319
356
  }
@@ -327,38 +364,40 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
327
364
  })
328
365
 
329
366
  // ── Route progress observer ───────────────────────────────────────────
330
- // EXACT same pattern as TurnByTurnExperienceActivity.routeProgressObserver
331
367
  nav.registerRouteProgressObserver(RouteProgressObserver { routeProgress ->
332
- // Update camera
368
+ // Update viewport with progress (camera follows route progress)
333
369
  viewportDataSource.onRouteProgressChanged(routeProgress)
334
370
  viewportDataSource.evaluate()
335
371
 
336
372
  // Draw maneuver arrow
337
- val style = mapView.mapboxMap.style
338
- if (style != null) {
339
- val maneuverArrowResult = routeArrowApi.addUpcomingManeuverArrow(routeProgress)
340
- routeArrowView.renderManeuverUpdate(style, maneuverArrowResult)
373
+ mapView.mapboxMap.style?.let { style ->
374
+ val arrowResult = routeArrowApi.addUpcomingManeuverArrow(routeProgress)
375
+ routeArrowView.renderManeuverUpdate(style, arrowResult)
341
376
  }
342
377
 
343
- // Maneuver banner — EXACT same fold() pattern as official example
378
+ // Maneuver banner
344
379
  val maneuvers = maneuverApi.getManeuvers(routeProgress)
345
380
  maneuvers.fold(
346
- { error -> Log.w(TAG, "Maneuver error: ${error.errorMessage}") },
347
- {
381
+ { error ->
382
+ Log.w(TAG, "Maneuver error: ${error.errorMessage}")
383
+ Unit
384
+ },
385
+ { _ ->
348
386
  maneuverView?.visibility = View.VISIBLE
349
387
  maneuverView?.renderManeuvers(maneuvers)
388
+ Unit
350
389
  }
351
390
  )
352
391
 
353
- // Trip progress — EXACT same as official example
354
- // getTripProgress returns TripProgressUpdateValue directly (not nullable)
392
+ // Trip progress
355
393
  val tripProgress = tripProgressApi.getTripProgress(routeProgress)
356
394
  tripProgressView?.render(tripProgress)
395
+ updateEtaBar(
396
+ tripProgress.estimatedTimeToArrival,
397
+ tripProgress.totalTimeRemaining,
398
+ tripProgress.distanceRemaining
399
+ )
357
400
 
358
- // Update our custom ETA bar from tripProgress fields
359
- updateEtaBar(tripProgress)
360
-
361
- // Emit event
362
401
  onRouteProgressChanged(mapOf(
363
402
  "distanceRemaining" to routeProgress.distanceRemaining,
364
403
  "durationRemaining" to routeProgress.durationRemaining,
@@ -371,29 +410,33 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
371
410
  })
372
411
 
373
412
  // ── Location observer ─────────────────────────────────────────────────
374
- // EXACT same as official example
375
413
  nav.registerLocationObserver(object : LocationObserver {
376
414
  override fun onNewRawLocation(rawLocation: Location) {}
377
415
 
378
- override fun onNewLocationMatcherResult(locationMatcherResult: LocationMatcherResult) {
379
- val enhancedLocation = locationMatcherResult.enhancedLocation
416
+ override fun onNewLocationMatcherResult(result: LocationMatcherResult) {
417
+ val loc = result.enhancedLocation
380
418
 
381
- // Update location puck
419
+ // Update location puck position
382
420
  navigationLocationProvider.changePosition(
383
- location = enhancedLocation,
384
- keyPoints = locationMatcherResult.keyPoints,
421
+ location = loc,
422
+ keyPoints = result.keyPoints,
385
423
  )
386
424
 
387
- // Update camera
388
- viewportDataSource.onLocationChanged(enhancedLocation)
425
+ // FIX: Update viewport data source with location AND bearing
426
+ // This is what drives the camera to follow the vehicle's direction
427
+ viewportDataSource.onLocationChanged(loc)
389
428
  viewportDataSource.evaluate()
390
429
 
391
- // Speed limit display
392
- val formatterOptions = DistanceFormatterOptions.Builder(context).build()
393
- val speedInfo = speedInfoApi.updatePostedAndCurrentSpeed(
394
- locationMatcherResult,
395
- formatterOptions
396
- )
430
+ // FIX: On first location fix, immediately enter following mode
431
+ // This ensures the camera centers on the user as soon as GPS is available
432
+ if (!firstLocationReceived) {
433
+ firstLocationReceived = true
434
+ safeCameraFollowing()
435
+ }
436
+
437
+ // ── Speed limit display ────────────────────────────────────────
438
+ val fmtOptions = DistanceFormatterOptions.Builder(context).build()
439
+ val speedInfo = speedInfoApi.updatePostedAndCurrentSpeed(result, fmtOptions)
397
440
  if (speedInfo != null) {
398
441
  speedInfoView?.visibility = View.VISIBLE
399
442
  speedInfoView?.render(speedInfo)
@@ -401,45 +444,46 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
401
444
  speedInfoView?.visibility = View.GONE
402
445
  }
403
446
 
404
- // Auto day/night switch
405
447
  checkAndSwitchDayNight()
406
448
  }
407
449
  })
408
450
  }
409
451
 
410
452
  // ─────────────────────────────────────────────────────────────────────────
411
- // 5. ETA bar update
412
- // TripProgressUpdateValue fields confirmed from official tripdata API
453
+ // ETA bar update
454
+ // Fields confirmed from TripProgressUpdateValue docs:
455
+ // estimatedTimeToArrival: Long — Unix timestamp ms
456
+ // totalTimeRemaining: Double — seconds
457
+ // distanceRemaining: Double — metres
413
458
  // ─────────────────────────────────────────────────────────────────────────
414
- private fun updateEtaBar(tripProgress: com.mapbox.navigation.tripdata.progress.model.TripProgressUpdateValue) {
415
- // ETA arrival time
416
- val etaSec = tripProgress.formatter.timeRemainingFormatter
417
- .formatTime(tripProgress).toString()
418
- // Calculate arrival clock time
419
- val totalSec = tripProgress.totalTimeRemaining.toLong()
420
- val cal = Calendar.getInstance().apply { add(Calendar.SECOND, totalSec.toInt()) }
421
- tvEtaTime?.text = String.format("%02d:%02d",
422
- cal.get(Calendar.HOUR_OF_DAY),
423
- cal.get(Calendar.MINUTE)
459
+ private fun updateEtaBar(
460
+ estimatedTimeToArrivalMs: Long,
461
+ totalTimeRemainingSec: Double,
462
+ distanceRemainingMetres: Double
463
+ ) {
464
+ val arrivalCal = Calendar.getInstance().apply {
465
+ timeInMillis = estimatedTimeToArrivalMs
466
+ }
467
+ tvEtaTime?.text = String.format(
468
+ "%02d:%02d",
469
+ arrivalCal.get(Calendar.HOUR_OF_DAY),
470
+ arrivalCal.get(Calendar.MINUTE)
424
471
  )
425
472
 
426
- // Duration remaining
427
- val totalMin = (tripProgress.totalTimeRemaining / 60).toInt()
473
+ val totalMin = (totalTimeRemainingSec / 60).toInt()
428
474
  tvDuration?.text = if (totalMin >= 60)
429
475
  "${totalMin / 60}h ${totalMin % 60}min"
430
476
  else
431
477
  "${totalMin} min"
432
478
 
433
- // Distance remaining
434
- val distM = tripProgress.distanceRemaining
435
- tvDistance?.text = if (distM >= 1000)
436
- String.format("%.1f km", distM / 1000.0)
479
+ tvDistance?.text = if (distanceRemainingMetres >= 1000)
480
+ String.format("%.1f km", distanceRemainingMetres / 1000.0)
437
481
  else
438
- "${distM.toInt()} m"
482
+ "${distanceRemainingMetres.toInt()} m"
439
483
  }
440
484
 
441
485
  // ─────────────────────────────────────────────────────────────────────────
442
- // 6. Day / Night auto switch
486
+ // Day / Night auto switch
443
487
  // ─────────────────────────────────────────────────────────────────────────
444
488
  private fun getAutoStyle(): String {
445
489
  val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
@@ -452,10 +496,8 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
452
496
  val shouldBeNight = hour !in 6..20
453
497
  if (shouldBeNight == isNightMode) return
454
498
  isNightMode = shouldBeNight
455
-
456
499
  val newStyle = if (shouldBeNight) NavigationStyles.NAVIGATION_NIGHT_STYLE
457
500
  else NavigationStyles.NAVIGATION_DAY_STYLE
458
-
459
501
  mapView.mapboxMap.loadStyle(newStyle) { style ->
460
502
  routeLineView.initializeLayers(style)
461
503
  mapboxNavigation?.getNavigationRoutes()?.takeIf { it.isNotEmpty() }?.let { routes ->
@@ -464,26 +506,40 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
464
506
  }
465
507
  }
466
508
  }
509
+ }
467
510
 
468
- etaBar?.setBackgroundColor(
469
- if (shouldBeNight) Color.parseColor("#1E2433") else Color.WHITE
470
- )
471
- val tc = if (shouldBeNight) Color.WHITE else Color.BLACK
472
- tvEtaTime?.setTextColor(tc)
473
- tvDuration?.setTextColor(if (shouldBeNight) Color.LTGRAY else Color.DKGRAY)
474
- tvDistance?.setTextColor(Color.GRAY)
511
+ // ─────────────────────────────────────────────────────────────────────────
512
+ // Camera following — safe wrapper (issue #43)
513
+ // FIX: maxDuration(0) = instant transition, no animation delay
514
+ // ─────────────────────────────────────────────────────────────────────────
515
+ private fun safeCameraFollowing() {
516
+ try {
517
+ navigationCamera.requestNavigationCameraToFollowing(
518
+ stateTransitionOptions = NavigationCameraTransitionOptions.Builder()
519
+ .maxDuration(0)
520
+ .build()
521
+ )
522
+ } catch (e: Exception) {
523
+ Log.e(TAG, "Camera following error: ${e.message}")
524
+ }
475
525
  }
476
526
 
477
527
  // ─────────────────────────────────────────────────────────────────────────
478
- // 7. Cancel navigation
528
+ // Cancel navigation — also stops foreground service (fixes notification crash)
479
529
  // ─────────────────────────────────────────────────────────────────────────
480
530
  private fun cancelNavigation() {
481
531
  mapboxNavigation?.setNavigationRoutes(listOf())
482
532
  mapboxNavigation?.stopTripSession()
533
+ firstLocationReceived = false
483
534
  hideUI()
484
535
  onNavigationCancelled(mapOf<String, Any>())
485
536
  }
486
537
 
538
+ private fun showUI() {
539
+ maneuverView?.visibility = View.VISIBLE
540
+ etaBar?.visibility = View.VISIBLE
541
+ }
542
+
487
543
  private fun hideUI() {
488
544
  maneuverView?.visibility = View.INVISIBLE
489
545
  speedInfoView?.visibility = View.GONE
@@ -491,22 +547,7 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
491
547
  }
492
548
 
493
549
  // ─────────────────────────────────────────────────────────────────────────
494
- // 8. Issue #43safe camera following
495
- // ─────────────────────────────────────────────────────────────────────────
496
- private fun safeCameraFollowing() {
497
- try {
498
- navigationCamera.requestNavigationCameraToFollowing(
499
- stateTransitionOptions = NavigationCameraTransitionOptions.Builder()
500
- .maxDuration(0)
501
- .build()
502
- )
503
- } catch (e: Exception) {
504
- Log.e(TAG, "Camera transition error: ${e.message}")
505
- }
506
- }
507
-
508
- // ─────────────────────────────────────────────────────────────────────────
509
- // 9. Issue #31 — Voice units
550
+ // Issue #31Voice units
510
551
  // ─────────────────────────────────────────────────────────────────────────
511
552
  private fun resolveVoiceUnits(): String {
512
553
  return when (voiceUnits?.lowercase()) {
@@ -520,15 +561,15 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
520
561
  }
521
562
 
522
563
  // ─────────────────────────────────────────────────────────────────────────
523
- // 10. Route request
564
+ // Route request
524
565
  // ─────────────────────────────────────────────────────────────────────────
525
566
  @SuppressLint("MissingPermission")
526
567
  private fun fetchRoutes() {
527
568
  val nav = mapboxNavigation ?: return
528
569
  if (coordinates.size < 2) return
529
570
 
530
- val points = coordinates.map { coord ->
531
- Point.fromLngLat(coord["longitude"] ?: 0.0, coord["latitude"] ?: 0.0)
571
+ val points = coordinates.map {
572
+ Point.fromLngLat(it["longitude"] ?: 0.0, it["latitude"] ?: 0.0)
532
573
  }
533
574
  val locale = language?.let { Locale.forLanguageTag(it) } ?: Locale.getDefault()
534
575
 
@@ -537,46 +578,47 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
537
578
  .language(locale.toLanguageTag())
538
579
  .voiceUnits(resolveVoiceUnits())
539
580
  .coordinatesList(points)
581
+ // maxspeed annotation required for speed limit display
540
582
  .annotations("maxspeed,congestion,duration")
541
583
 
542
584
  waypointIndices?.let { builder.waypointIndicesList(it) }
543
585
  navigationProfile?.let {
544
586
  builder.profile(if (it.startsWith("mapbox/")) it else "mapbox/$it")
545
587
  }
546
- excludeTypes?.takeIf { it.isNotEmpty() }?.let { builder.exclude(it.joinToString(",")) }
588
+ excludeTypes?.takeIf { it.isNotEmpty() }?.let {
589
+ builder.exclude(it.joinToString(","))
590
+ }
547
591
  maxHeight?.let { builder.maxHeight(it) }
548
592
  maxWidth?.let { builder.maxWidth(it) }
549
593
 
550
- nav.requestRoutes(
551
- builder.build(),
552
- object : NavigationRouterCallback {
553
- override fun onRoutesReady(
554
- routes: List<NavigationRoute>,
555
- @RouterOrigin routerOrigin: String
556
- ) {
557
- if (routes.isEmpty()) {
558
- onRoutesFailed(mapOf("message" to "No routes returned"))
559
- return
560
- }
561
- nav.setNavigationRoutes(routes)
562
- nav.startTripSession()
563
- safeCameraFollowing()
594
+ nav.requestRoutes(builder.build(), object : NavigationRouterCallback {
595
+ override fun onRoutesReady(
596
+ routes: List<NavigationRoute>,
597
+ @RouterOrigin routerOrigin: String
598
+ ) {
599
+ if (routes.isEmpty()) {
600
+ onRoutesFailed(mapOf("message" to "No routes returned"))
601
+ return
564
602
  }
603
+ nav.setNavigationRoutes(routes)
604
+ nav.startTripSession()
605
+ // Camera will follow automatically via onNewLocationMatcherResult
606
+ // + onRoutesChanged observer
607
+ }
565
608
 
566
- override fun onFailure(reasons: List<RouterFailure>, routeOptions: RouteOptions) {
567
- val msg = reasons.firstOrNull()?.message ?: "Unknown error"
568
- Log.e(TAG, "Route request failed: $msg")
569
- onRoutesFailed(mapOf("message" to msg))
570
- }
609
+ override fun onFailure(reasons: List<RouterFailure>, routeOptions: RouteOptions) {
610
+ val msg = reasons.firstOrNull()?.message ?: "Unknown error"
611
+ Log.e(TAG, "Route failed: $msg")
612
+ onRoutesFailed(mapOf("message" to msg))
613
+ }
571
614
 
572
- override fun onCanceled(
573
- routeOptions: RouteOptions,
574
- @RouterOrigin routerOrigin: String
575
- ) {
576
- Log.d(TAG, "Route request cancelled")
577
- }
615
+ override fun onCanceled(
616
+ routeOptions: RouteOptions,
617
+ @RouterOrigin routerOrigin: String
618
+ ) {
619
+ Log.d(TAG, "Route cancelled")
578
620
  }
579
- )
621
+ })
580
622
  }
581
623
 
582
624
  // ── Prop setters ──────────────────────────────────────────────────────────
@@ -584,18 +626,18 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
584
626
  coordinates = coords
585
627
  if (coords.size >= 2) fetchRoutes()
586
628
  }
587
- fun setWaypointIndices(indices: List<Int>?) { waypointIndices = indices }
588
- fun setLanguage(lang: String?) { language = lang }
589
- fun setVoiceUnits(units: String?) { voiceUnits = units }
590
- fun setNavigationProfile(profile: String?) { navigationProfile = profile }
591
- fun setExcludeTypes(types: List<String>?) { excludeTypes = types }
592
- fun setMapStyle(style: String?) { mapStyle = style }
593
- fun setMute(shouldMute: Boolean) { mute = shouldMute }
594
- fun setMaxHeight(height: Double?) { maxHeight = height }
595
- fun setMaxWidth(width: Double?) { maxWidth = width }
596
- fun setUseMapMatching(use: Boolean) { useMapMatching = use }
597
- fun setCustomRasterTileUrl(url: String?) { customRasterTileUrl = url }
598
- fun setCustomRasterAboveLayerId(layerId: String?) { customRasterAboveLayerId = layerId }
629
+ fun setWaypointIndices(i: List<Int>?) { waypointIndices = i }
630
+ fun setLanguage(l: String?) { language = l }
631
+ fun setVoiceUnits(u: String?) { voiceUnits = u }
632
+ fun setNavigationProfile(p: String?) { navigationProfile = p }
633
+ fun setExcludeTypes(t: List<String>?) { excludeTypes = t }
634
+ fun setMapStyle(s: String?) { mapStyle = s }
635
+ fun setMute(m: Boolean) { mute = m }
636
+ fun setMaxHeight(h: Double?) { maxHeight = h }
637
+ fun setMaxWidth(w: Double?) { maxWidth = w }
638
+ fun setUseMapMatching(u: Boolean) { useMapMatching = u }
639
+ fun setCustomRasterTileUrl(u: String?) { customRasterTileUrl = u }
640
+ fun setCustomRasterAboveLayerId(l: String?) { customRasterAboveLayerId = l }
599
641
 
600
642
  // ── Lifecycle ─────────────────────────────────────────────────────────────
601
643
  override fun onDetachedFromWindow() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacques_gordon/expo-mapbox-navigation",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
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",