@jacques_gordon/expo-mapbox-navigation 2.1.2 → 2.1.4

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.
@@ -74,6 +74,9 @@ dependencies {
74
74
  // UI Maps (NavigationStyles, NavigationLocationProvider, NavigationCamera)
75
75
  implementation "com.mapbox.navigationcore:ui-maps:${MAPBOX_NAV_VERSION}"
76
76
 
77
+ // Voice instructions (MapboxSpeechApi, MapboxVoiceInstructionsPlayer, SpeechVolume)
78
+ implementation "com.mapbox.navigationcore:voice:${MAPBOX_NAV_VERSION}"
79
+
77
80
  // ConstraintLayout — required because MapboxManeuverView extends ConstraintLayout
78
81
  implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
79
82
 
@@ -3,18 +3,31 @@ package expo.modules.mapboxnavigation
3
3
  import android.annotation.SuppressLint
4
4
  import android.content.Context
5
5
  import android.content.res.Resources
6
+ import android.graphics.Bitmap
7
+ import android.graphics.Canvas
6
8
  import android.graphics.Color
9
+ import android.graphics.Paint
10
+ import android.graphics.Path
11
+ import android.graphics.PorterDuff
12
+ import android.graphics.PorterDuffXfermode
13
+ import android.graphics.RectF
14
+ import android.graphics.drawable.BitmapDrawable
15
+ import android.graphics.drawable.GradientDrawable
7
16
  import android.util.Log
8
17
  import android.view.Gravity
9
18
  import android.view.View
10
19
  import android.widget.FrameLayout
20
+ import android.widget.ImageView
11
21
  import android.widget.LinearLayout
12
22
  import android.widget.TextView
13
23
  import com.mapbox.api.directions.v5.models.RouteOptions
14
24
  import com.mapbox.common.location.Location
15
25
  import com.mapbox.geojson.Point
16
26
  import com.mapbox.maps.EdgeInsets
27
+ import com.mapbox.maps.ImageHolder
17
28
  import com.mapbox.maps.MapView
29
+ import com.mapbox.maps.plugin.LocationPuck2D
30
+ import com.mapbox.maps.plugin.PuckBearing
18
31
  import com.mapbox.maps.plugin.animation.camera
19
32
  import com.mapbox.maps.plugin.locationcomponent.location
20
33
  import com.mapbox.navigation.base.TimeFormat
@@ -33,6 +46,7 @@ import com.mapbox.navigation.core.formatter.MapboxDistanceFormatter
33
46
  import com.mapbox.navigation.core.trip.session.LocationMatcherResult
34
47
  import com.mapbox.navigation.core.trip.session.LocationObserver
35
48
  import com.mapbox.navigation.core.trip.session.RouteProgressObserver
49
+ import com.mapbox.navigation.core.trip.session.VoiceInstructionsObserver
36
50
  import com.mapbox.navigation.tripdata.maneuver.api.MapboxManeuverApi
37
51
  import com.mapbox.navigation.tripdata.progress.api.MapboxTripProgressApi
38
52
  import com.mapbox.navigation.tripdata.progress.model.DistanceRemainingFormatter
@@ -55,6 +69,12 @@ import com.mapbox.navigation.ui.maps.route.line.api.MapboxRouteLineApi
55
69
  import com.mapbox.navigation.ui.maps.route.line.api.MapboxRouteLineView
56
70
  import com.mapbox.navigation.ui.maps.route.line.model.MapboxRouteLineApiOptions
57
71
  import com.mapbox.navigation.ui.maps.route.line.model.MapboxRouteLineViewOptions
72
+ import com.mapbox.navigation.voice.api.MapboxSpeechApi
73
+ import com.mapbox.navigation.voice.api.MapboxVoiceInstructionsPlayer
74
+ import com.mapbox.navigation.voice.model.SpeechAnnouncement
75
+ import com.mapbox.navigation.voice.model.SpeechError
76
+ import com.mapbox.navigation.voice.model.SpeechValue
77
+ import com.mapbox.navigation.voice.model.SpeechVolume
58
78
  import expo.modules.kotlin.AppContext
59
79
  import expo.modules.kotlin.viewevent.EventDispatcher
60
80
  import expo.modules.kotlin.views.ExpoView
@@ -83,6 +103,10 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
83
103
  private var tvDuration: TextView? = null
84
104
  private var tvDistance: TextView? = null
85
105
  private var etaBar: LinearLayout? = null
106
+ private var btnMuteView: ImageView? = null
107
+ private var btnOverviewView: ImageView? = null
108
+ private var btnRecenterView: ImageView? = null
109
+ private var sideButtons: LinearLayout? = null
86
110
 
87
111
  // ── Navigation APIs ───────────────────────────────────────────────────────
88
112
  private lateinit var navigationCamera: NavigationCamera
@@ -96,29 +120,26 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
96
120
  private lateinit var routeArrowView: MapboxRouteArrowView
97
121
  private val navigationLocationProvider = NavigationLocationProvider()
98
122
  private var mapboxNavigation: MapboxNavigation? = null
99
- private var isNightMode = false
100
123
 
101
- // ── FIX: Track whether first location has been received ───────────────────
102
- // Without this, camera never enters following mode on first GPS fix
124
+ // ── Voice APIs ────────────────────────────────────────────────────────────
125
+ private lateinit var speechApi: MapboxSpeechApi
126
+ private lateinit var voiceInstructionsPlayer: MapboxVoiceInstructionsPlayer
127
+
128
+ // ── State ─────────────────────────────────────────────────────────────────
129
+ private var isNightMode = false
130
+ private var isMuted = false
131
+ private var isOverviewMode = false
103
132
  private var firstLocationReceived = false
104
133
 
105
- // ── Viewport padding (from official TurnByTurnExperienceActivity) ─────────
106
- private val pixelDensity = Resources.getSystem().displayMetrics.density
134
+ // ── Pixel density ─────────────────────────────────────────────────────────
135
+ private val dp = context.resources.displayMetrics.density
136
+
137
+ // ── Viewport padding ──────────────────────────────────────────────────────
107
138
  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
- )
139
+ EdgeInsets(180.0 * dp, 40.0 * dp, 300.0 * dp, 40.0 * dp)
114
140
  }
115
141
  private val overviewPadding by lazy {
116
- EdgeInsets(
117
- 140.0 * pixelDensity,
118
- 40.0 * pixelDensity,
119
- 120.0 * pixelDensity,
120
- 40.0 * pixelDensity
121
- )
142
+ EdgeInsets(120.0 * dp, 40.0 * dp, 120.0 * dp, 40.0 * dp)
122
143
  }
123
144
 
124
145
  // ── Props ─────────────────────────────────────────────────────────────────
@@ -142,23 +163,138 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
142
163
  setupNavigation()
143
164
  }
144
165
 
166
+ // ─────────────────────────────────────────────────────────────────────────
167
+ // Draw icons programmatically (matching screenshot: speaker, route, arrow)
168
+ // ─────────────────────────────────────────────────────────────────────────
169
+
170
+ private fun drawSpeakerIcon(muted: Boolean): Bitmap {
171
+ val size = (44 * dp).toInt()
172
+ val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
173
+ val c = Canvas(bmp)
174
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
175
+ color = Color.BLACK
176
+ style = Paint.Style.FILL
177
+ }
178
+ val s = size.toFloat()
179
+ // Speaker body (trapezoid)
180
+ val body = Path().apply {
181
+ moveTo(s * 0.18f, s * 0.38f)
182
+ lineTo(s * 0.38f, s * 0.38f)
183
+ lineTo(s * 0.58f, s * 0.22f)
184
+ lineTo(s * 0.58f, s * 0.78f)
185
+ lineTo(s * 0.38f, s * 0.62f)
186
+ lineTo(s * 0.18f, s * 0.62f)
187
+ close()
188
+ }
189
+ c.drawPath(body, paint)
190
+ if (!muted) {
191
+ // Sound waves arcs
192
+ paint.style = Paint.Style.STROKE
193
+ paint.strokeWidth = s * 0.065f
194
+ paint.strokeCap = Paint.Cap.ROUND
195
+ // Inner arc
196
+ c.drawArc(RectF(s*0.60f, s*0.33f, s*0.80f, s*0.67f), -60f, 120f, false, paint)
197
+ // Outer arc
198
+ c.drawArc(RectF(s*0.64f, s*0.24f, s*0.92f, s*0.76f), -55f, 110f, false, paint)
199
+ } else {
200
+ // X mark for muted
201
+ paint.style = Paint.Style.STROKE
202
+ paint.strokeWidth = s * 0.07f
203
+ paint.strokeCap = Paint.Cap.ROUND
204
+ paint.color = Color.parseColor("#FF4444")
205
+ c.drawLine(s*0.65f, s*0.32f, s*0.88f, s*0.68f, paint)
206
+ c.drawLine(s*0.88f, s*0.32f, s*0.65f, s*0.68f, paint)
207
+ }
208
+ return bmp
209
+ }
210
+
211
+ private fun drawRouteOverviewIcon(): Bitmap {
212
+ val size = (44 * dp).toInt()
213
+ val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
214
+ val c = Canvas(bmp)
215
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
216
+ color = Color.BLACK
217
+ style = Paint.Style.STROKE
218
+ strokeWidth = size * 0.08f
219
+ strokeCap = Paint.Cap.ROUND
220
+ strokeJoin = Paint.Join.ROUND
221
+ }
222
+ val s = size.toFloat()
223
+ // Route line with S-curve (like the image: start dot → curve → end pin)
224
+ // Start dot
225
+ paint.style = Paint.Style.FILL
226
+ c.drawCircle(s * 0.28f, s * 0.75f, s * 0.07f, paint)
227
+ // Route path
228
+ paint.style = Paint.Style.STROKE
229
+ val routePath = Path().apply {
230
+ moveTo(s * 0.28f, s * 0.75f)
231
+ cubicTo(
232
+ s * 0.28f, s * 0.45f,
233
+ s * 0.72f, s * 0.55f,
234
+ s * 0.72f, s * 0.25f
235
+ )
236
+ }
237
+ c.drawPath(routePath, paint)
238
+ // End pin (circle with dot)
239
+ paint.style = Paint.Style.STROKE
240
+ c.drawCircle(s * 0.72f, s * 0.22f, s * 0.10f, paint)
241
+ paint.style = Paint.Style.FILL
242
+ c.drawCircle(s * 0.72f, s * 0.22f, s * 0.04f, paint)
243
+ return bmp
244
+ }
245
+
246
+ private fun drawNavigationArrowIcon(): Bitmap {
247
+ val size = (44 * dp).toInt()
248
+ val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
249
+ val c = Canvas(bmp)
250
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
251
+ color = Color.BLACK
252
+ style = Paint.Style.FILL
253
+ }
254
+ val s = size.toFloat()
255
+ // Navigation arrow pointing up (filled chevron/arrow like Waze)
256
+ val arrow = Path().apply {
257
+ moveTo(s * 0.50f, s * 0.14f) // tip
258
+ lineTo(s * 0.78f, s * 0.76f) // bottom-right
259
+ lineTo(s * 0.50f, s * 0.60f) // bottom-center indent
260
+ lineTo(s * 0.22f, s * 0.76f) // bottom-left
261
+ close()
262
+ }
263
+ c.drawPath(arrow, paint)
264
+ return bmp
265
+ }
266
+
267
+ private fun makeIconButton(bitmap: Bitmap, onClick: () -> Unit): ImageView {
268
+ val size = (56 * dp).toInt()
269
+ val iv = ImageView(context).apply {
270
+ setImageBitmap(bitmap)
271
+ scaleType = ImageView.ScaleType.CENTER_INSIDE
272
+ setPadding((8 * dp).toInt(), (8 * dp).toInt(), (8 * dp).toInt(), (8 * dp).toInt())
273
+ background = GradientDrawable().apply {
274
+ shape = GradientDrawable.OVAL
275
+ setColor(Color.WHITE)
276
+ }
277
+ elevation = 6 * dp
278
+ setOnClickListener { onClick() }
279
+ }
280
+ return iv
281
+ }
282
+
145
283
  // ─────────────────────────────────────────────────────────────────────────
146
284
  // Build UI
147
285
  // ─────────────────────────────────────────────────────────────────────────
148
286
  private fun buildUI() {
149
- val dp = context.resources.displayMetrics.density
150
287
  val root = FrameLayout(context).apply {
151
288
  layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
152
289
  }
153
290
 
154
- // Full screen map
291
+ // Full-screen map
155
292
  root.addView(mapView, FrameLayout.LayoutParams(
156
293
  FrameLayout.LayoutParams.MATCH_PARENT,
157
294
  FrameLayout.LayoutParams.MATCH_PARENT
158
295
  ))
159
296
 
160
- // ── ManeuverView — top banner ──────────────────────────────────────────
161
- // extends ConstraintLayout → must cast to View for addView()
297
+ // ── ManeuverView — top ─────────────────────────────────────────────────
162
298
  val mv = MapboxManeuverView(context)
163
299
  root.addView(mv as View, FrameLayout.LayoutParams(
164
300
  FrameLayout.LayoutParams.MATCH_PARENT,
@@ -167,16 +303,53 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
167
303
  mv.visibility = View.INVISIBLE
168
304
  maneuverView = mv
169
305
 
170
- // ── SpeedInfoViewbottom-left ────────────────────────────────────────
171
- // Shows posted speed limit + current vehicle speed
172
- val siv = MapboxSpeedInfoView(context)
173
- root.addView(siv as View, FrameLayout.LayoutParams(
306
+ // ── Side buttons RIGHT side, just below maneuver banner ─────────────
307
+ // Position: top margin = maneuver banner height (~180dp) + 8dp gap
308
+ val sideCol = LinearLayout(context).apply {
309
+ orientation = LinearLayout.VERTICAL
310
+ gravity = Gravity.CENTER_HORIZONTAL
311
+ visibility = View.INVISIBLE
312
+ }
313
+ root.addView(sideCol, FrameLayout.LayoutParams(
174
314
  FrameLayout.LayoutParams.WRAP_CONTENT,
175
315
  FrameLayout.LayoutParams.WRAP_CONTENT
316
+ ).also {
317
+ it.gravity = Gravity.TOP or Gravity.END
318
+ it.setMargins(0, (188 * dp).toInt(), (12 * dp).toInt(), 0)
319
+ })
320
+ sideButtons = sideCol
321
+
322
+ val btnSize = (56 * dp).toInt()
323
+ val btnParams = LinearLayout.LayoutParams(btnSize, btnSize).also {
324
+ it.bottomMargin = (10 * dp).toInt()
325
+ }
326
+
327
+ // Button 1: Mute/Unmute (speaker icon)
328
+ val muteBtn = makeIconButton(drawSpeakerIcon(false)) { toggleMute() }
329
+ sideCol.addView(muteBtn, btnParams)
330
+ btnMuteView = muteBtn
331
+
332
+ // Button 2: Overview / Route view
333
+ val overviewBtn = makeIconButton(drawRouteOverviewIcon()) { toggleOverview() }
334
+ sideCol.addView(overviewBtn, LinearLayout.LayoutParams(btnSize, btnSize).also {
335
+ it.bottomMargin = (10 * dp).toInt()
336
+ })
337
+ btnOverviewView = overviewBtn
338
+
339
+ // Button 3: Recenter / Navigation arrow (hidden until in overview mode)
340
+ val recenterBtn = makeIconButton(drawNavigationArrowIcon()) { recenterCamera() }
341
+ recenterBtn.visibility = View.GONE
342
+ sideCol.addView(recenterBtn, LinearLayout.LayoutParams(btnSize, btnSize))
343
+ btnRecenterView = recenterBtn
344
+
345
+ // ── SpeedInfoView — bottom-left, above ETA bar ────────────────────────
346
+ val siv = MapboxSpeedInfoView(context)
347
+ root.addView(siv as View, FrameLayout.LayoutParams(
348
+ (72 * dp).toInt(),
349
+ (72 * dp).toInt()
176
350
  ).also {
177
351
  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())
352
+ it.setMargins((12 * dp).toInt(), 0, 0, (88 * dp).toInt())
180
353
  })
181
354
  siv.visibility = View.GONE
182
355
  speedInfoView = siv
@@ -184,7 +357,7 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
184
357
  // ── ETA bottom bar ─────────────────────────────────────────────────────
185
358
  val bar = LinearLayout(context).apply {
186
359
  orientation = LinearLayout.HORIZONTAL
187
- setBackgroundColor(Color.parseColor("#1E2433")) // dark nav style
360
+ setBackgroundColor(Color.parseColor("#1E2433"))
188
361
  elevation = 8 * dp
189
362
  visibility = View.INVISIBLE
190
363
  gravity = Gravity.CENTER_VERTICAL
@@ -198,58 +371,38 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
198
371
  FrameLayout.LayoutParams.WRAP_CONTENT
199
372
  ).also { it.gravity = Gravity.BOTTOM })
200
373
 
201
- // ETA arrival time
202
374
  val etaTime = TextView(context).apply {
203
- textSize = 24f
204
- setTextColor(Color.WHITE)
375
+ textSize = 24f; setTextColor(Color.WHITE)
205
376
  typeface = android.graphics.Typeface.DEFAULT_BOLD
206
377
  }
207
- bar.addView(etaTime, LinearLayout.LayoutParams(
208
- 0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f
209
- ))
378
+ bar.addView(etaTime, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
210
379
  tvEtaTime = etaTime
211
380
 
212
- // Duration + distance (center)
213
381
  val center = LinearLayout(context).apply {
214
- orientation = LinearLayout.VERTICAL
215
- gravity = Gravity.CENTER
382
+ orientation = LinearLayout.VERTICAL; gravity = Gravity.CENTER
216
383
  }
217
384
  val dur = TextView(context).apply {
218
- textSize = 18f
219
- setTextColor(Color.WHITE)
220
- typeface = android.graphics.Typeface.DEFAULT_BOLD
221
- gravity = Gravity.CENTER
385
+ textSize = 18f; setTextColor(Color.WHITE)
386
+ typeface = android.graphics.Typeface.DEFAULT_BOLD; gravity = Gravity.CENTER
222
387
  }
223
388
  val dist = TextView(context).apply {
224
- textSize = 13f
225
- setTextColor(Color.parseColor("#AAAAAA"))
226
- gravity = Gravity.CENTER
389
+ textSize = 13f; setTextColor(Color.parseColor("#AAAAAA")); gravity = Gravity.CENTER
227
390
  }
228
- center.addView(dur)
229
- center.addView(dist)
230
- bar.addView(center, LinearLayout.LayoutParams(
231
- 0, LinearLayout.LayoutParams.WRAP_CONTENT, 2f
232
- ))
233
- tvDuration = dur
234
- tvDistance = dist
391
+ center.addView(dur); center.addView(dist)
392
+ bar.addView(center, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 2f))
393
+ tvDuration = dur; tvDistance = dist
235
394
 
236
- // Cancel button
237
395
  val cancelBtn = TextView(context).apply {
238
- text = "✕"
239
- textSize = 22f
240
- setTextColor(Color.parseColor("#AAAAAA"))
396
+ text = "✕"; textSize = 22f; setTextColor(Color.parseColor("#AAAAAA"))
241
397
  gravity = Gravity.END or Gravity.CENTER_VERTICAL
242
398
  setOnClickListener { cancelNavigation() }
243
399
  }
244
- bar.addView(cancelBtn, LinearLayout.LayoutParams(
245
- 0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f
246
- ))
400
+ bar.addView(cancelBtn, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
247
401
  etaBar = bar
248
402
 
249
- // ── TripProgressView — hidden native view (used for render() API) ──────
250
- // FIX: was created but never added to root — now added but invisible
403
+ // TripProgressView — 1×1 hidden (required for render() API)
251
404
  val tpv = MapboxTripProgressView(context)
252
- root.addView(tpv as View, FrameLayout.LayoutParams(1, 1)) // 1x1 hidden
405
+ root.addView(tpv as View, FrameLayout.LayoutParams(1, 1))
253
406
  tpv.visibility = View.GONE
254
407
  tripProgressView = tpv
255
408
 
@@ -284,6 +437,55 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
284
437
  )
285
438
 
286
439
  routeArrowView = MapboxRouteArrowView(RouteArrowOptions.Builder(context).build())
440
+
441
+ // ── Voice APIs ─────────────────────────────────────────────────────────
442
+ // Use device locale for TTS, fall back to "en" if needed
443
+ val voiceLang = language ?: Locale.getDefault().language.let {
444
+ if (it.isEmpty()) "en" else it
445
+ }
446
+ speechApi = MapboxSpeechApi(context, voiceLang)
447
+ voiceInstructionsPlayer = MapboxVoiceInstructionsPlayer(context, voiceLang)
448
+ }
449
+
450
+ // ── Voice callbacks (exact pattern from official TurnByTurnExperienceActivity) ──
451
+ // SpeechError and SpeechValue are swapped vs v2: fold(error, value) in v3
452
+ // speechCallback: Consumer<Expected<SpeechError, SpeechValue>>
453
+ // Exact pattern from official TurnByTurnExperienceActivity
454
+ // NOTE: in v3 fold order is (error, value) — SpeechError is left, SpeechValue is right
455
+ private val speechCallback =
456
+ com.mapbox.navigation.base.route.MapboxNavigationConsumer<
457
+ com.mapbox.bindgen.Expected<SpeechError, SpeechValue>
458
+ > { expected ->
459
+ expected.fold(
460
+ { error ->
461
+ // On-device TTS fallback when MP3 is not available
462
+ if (!isMuted) {
463
+ voiceInstructionsPlayer.play(
464
+ error.fallback,
465
+ voiceInstructionsPlayerCallback
466
+ )
467
+ }
468
+ },
469
+ { value ->
470
+ // Play the synthesized MP3 from Mapbox Voice API
471
+ if (!isMuted) {
472
+ voiceInstructionsPlayer.play(
473
+ value.announcement,
474
+ voiceInstructionsPlayerCallback
475
+ )
476
+ }
477
+ }
478
+ )
479
+ }
480
+
481
+ private val voiceInstructionsPlayerCallback =
482
+ { announcement: SpeechAnnouncement ->
483
+ // Cleanup the file after playing
484
+ speechApi.clean(announcement)
485
+ }
486
+
487
+ private val voiceInstructionsObserver = VoiceInstructionsObserver { voiceInstructions ->
488
+ speechApi.generate(voiceInstructions, speechCallback)
287
489
  }
288
490
 
289
491
  // ─────────────────────────────────────────────────────────────────────────
@@ -297,11 +499,7 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
297
499
  mapView.mapboxMap.loadStyle(mapStyle ?: getAutoStyle()) { style ->
298
500
  routeLineView.initializeLayers(style)
299
501
 
300
- // Camera viewport data source
301
502
  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
503
  viewportDataSource.followingPadding = followingPadding
306
504
  viewportDataSource.overviewPadding = overviewPadding
307
505
 
@@ -311,10 +509,19 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
311
509
  viewportDataSource
312
510
  )
313
511
 
314
- // Location puck
512
+ // Navigation arrow puck
315
513
  mapView.location.apply {
316
514
  setLocationProvider(navigationLocationProvider)
317
- enabled = true
515
+ updateSettings {
516
+ locationPuck = LocationPuck2D(
517
+ bearingImage = ImageHolder.from(
518
+ com.mapbox.navigation.R.drawable.mapbox_navigation_puck_icon
519
+ )
520
+ )
521
+ puckBearingEnabled = true
522
+ enabled = true
523
+ }
524
+ puckBearing = PuckBearing.COURSE
318
525
  }
319
526
 
320
527
  registerObservers()
@@ -327,23 +534,22 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
327
534
  private fun registerObservers() {
328
535
  val nav = mapboxNavigation ?: return
329
536
 
330
- // ── Routes observer ───────────────────────────────────────────────────
537
+ // Routes observer
331
538
  nav.registerRoutesObserver(object : RoutesObserver {
332
539
  override fun onRoutesChanged(result: RoutesUpdatedResult) {
333
540
  if (result.navigationRoutes.isNotEmpty()) {
541
+ // Cancel any in-flight speech when route changes
542
+ speechApi.cancel()
543
+ voiceInstructionsPlayer.clear()
544
+
334
545
  routeLineApi.setNavigationRoutes(result.navigationRoutes) { value ->
335
- mapView.mapboxMap.style?.apply {
336
- routeLineView.renderRouteDrawData(this, value)
337
- }
546
+ mapView.mapboxMap.style?.apply { routeLineView.renderRouteDrawData(this, value) }
338
547
  }
339
548
  viewportDataSource.onRouteChanged(result.navigationRoutes.first())
340
549
  viewportDataSource.evaluate()
341
-
342
- // FIX: transition to following mode when route is set
343
- // This triggers the camera to zoom/pitch/center correctly
550
+ isOverviewMode = false
344
551
  safeCameraFollowing()
345
552
  showUI()
346
-
347
553
  onRoutesReady(mapOf(
348
554
  "routeCount" to result.navigationRoutes.size,
349
555
  "distanceMeters" to (result.navigationRoutes.first().directionsRoute.distance() ?: 0.0),
@@ -363,25 +569,19 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
363
569
  }
364
570
  })
365
571
 
366
- // ── Route progress observer ───────────────────────────────────────────
572
+ // Route progress observer
367
573
  nav.registerRouteProgressObserver(RouteProgressObserver { routeProgress ->
368
- // Update viewport with progress (camera follows route progress)
369
574
  viewportDataSource.onRouteProgressChanged(routeProgress)
370
575
  viewportDataSource.evaluate()
371
576
 
372
- // Draw maneuver arrow
373
577
  mapView.mapboxMap.style?.let { style ->
374
578
  val arrowResult = routeArrowApi.addUpcomingManeuverArrow(routeProgress)
375
579
  routeArrowView.renderManeuverUpdate(style, arrowResult)
376
580
  }
377
581
 
378
- // Maneuver banner
379
582
  val maneuvers = maneuverApi.getManeuvers(routeProgress)
380
583
  maneuvers.fold(
381
- { error ->
382
- Log.w(TAG, "Maneuver error: ${error.errorMessage}")
383
- Unit
384
- },
584
+ { error -> Log.w(TAG, "Maneuver error: ${error.errorMessage}"); Unit },
385
585
  { _ ->
386
586
  maneuverView?.visibility = View.VISIBLE
387
587
  maneuverView?.renderManeuvers(maneuvers)
@@ -389,7 +589,6 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
389
589
  }
390
590
  )
391
591
 
392
- // Trip progress
393
592
  val tripProgress = tripProgressApi.getTripProgress(routeProgress)
394
593
  tripProgressView?.render(tripProgress)
395
594
  updateEtaBar(
@@ -404,37 +603,24 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
404
603
  "distanceTraveled" to routeProgress.distanceTraveled,
405
604
  "fractionTraveled" to routeProgress.fractionTraveled,
406
605
  "currentStepDistanceRemaining" to
407
- (routeProgress.currentLegProgress
408
- ?.currentStepProgress?.distanceRemaining ?: 0f)
606
+ (routeProgress.currentLegProgress?.currentStepProgress?.distanceRemaining ?: 0f)
409
607
  ))
410
608
  })
411
609
 
412
- // ── Location observer ─────────────────────────────────────────────────
610
+ // Location observer
413
611
  nav.registerLocationObserver(object : LocationObserver {
414
612
  override fun onNewRawLocation(rawLocation: Location) {}
415
-
416
613
  override fun onNewLocationMatcherResult(result: LocationMatcherResult) {
417
614
  val loc = result.enhancedLocation
418
-
419
- // Update location puck position
420
- navigationLocationProvider.changePosition(
421
- location = loc,
422
- keyPoints = result.keyPoints,
423
- )
424
-
425
- // FIX: Update viewport data source with location AND bearing
426
- // This is what drives the camera to follow the vehicle's direction
615
+ navigationLocationProvider.changePosition(location = loc, keyPoints = result.keyPoints)
427
616
  viewportDataSource.onLocationChanged(loc)
428
617
  viewportDataSource.evaluate()
429
618
 
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
619
  if (!firstLocationReceived) {
433
620
  firstLocationReceived = true
434
621
  safeCameraFollowing()
435
622
  }
436
623
 
437
- // ── Speed limit display ────────────────────────────────────────
438
624
  val fmtOptions = DistanceFormatterOptions.Builder(context).build()
439
625
  val speedInfo = speedInfoApi.updatePostedAndCurrentSpeed(result, fmtOptions)
440
626
  if (speedInfo != null) {
@@ -447,43 +633,63 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
447
633
  checkAndSwitchDayNight()
448
634
  }
449
635
  })
636
+
637
+ // Voice instructions observer — triggers TTS announcements
638
+ nav.registerVoiceInstructionsObserver(voiceInstructionsObserver)
639
+ }
640
+
641
+ // ─────────────────────────────────────────────────────────────────────────
642
+ // Button actions
643
+ // ─────────────────────────────────────────────────────────────────────────
644
+ private fun toggleMute() {
645
+ isMuted = !isMuted
646
+ // Update icon to show muted/unmuted state
647
+ btnMuteView?.setImageBitmap(drawSpeakerIcon(isMuted))
648
+ // Apply volume via SpeechVolume API
649
+ voiceInstructionsPlayer.volume(SpeechVolume(if (isMuted) 0f else 1f))
650
+ }
651
+
652
+ private fun toggleOverview() {
653
+ if (isOverviewMode) {
654
+ recenterCamera()
655
+ } else {
656
+ isOverviewMode = true
657
+ btnRecenterView?.visibility = View.VISIBLE
658
+ try {
659
+ navigationCamera.requestNavigationCameraToOverview()
660
+ } catch (e: Exception) {
661
+ Log.e(TAG, "Camera overview error: ${e.message}")
662
+ }
663
+ }
664
+ }
665
+
666
+ private fun recenterCamera() {
667
+ isOverviewMode = false
668
+ btnRecenterView?.visibility = View.GONE
669
+ safeCameraFollowing()
450
670
  }
451
671
 
452
672
  // ─────────────────────────────────────────────────────────────────────────
453
- // ETA bar update
454
- // Fields confirmed from TripProgressUpdateValue docs:
455
- // estimatedTimeToArrival: Long — Unix timestamp ms
456
- // totalTimeRemaining: Double — seconds
457
- // distanceRemaining: Double — metres
673
+ // ETA bar
458
674
  // ─────────────────────────────────────────────────────────────────────────
459
675
  private fun updateEtaBar(
460
676
  estimatedTimeToArrivalMs: Long,
461
677
  totalTimeRemainingSec: Double,
462
678
  distanceRemainingMetres: Double
463
679
  ) {
464
- val arrivalCal = Calendar.getInstance().apply {
465
- timeInMillis = estimatedTimeToArrivalMs
466
- }
680
+ val arrivalCal = Calendar.getInstance().apply { timeInMillis = estimatedTimeToArrivalMs }
467
681
  tvEtaTime?.text = String.format(
468
- "%02d:%02d",
469
- arrivalCal.get(Calendar.HOUR_OF_DAY),
470
- arrivalCal.get(Calendar.MINUTE)
682
+ "%02d:%02d", arrivalCal.get(Calendar.HOUR_OF_DAY), arrivalCal.get(Calendar.MINUTE)
471
683
  )
472
-
473
684
  val totalMin = (totalTimeRemainingSec / 60).toInt()
474
- tvDuration?.text = if (totalMin >= 60)
475
- "${totalMin / 60}h ${totalMin % 60}min"
476
- else
477
- "${totalMin} min"
478
-
685
+ tvDuration?.text = if (totalMin >= 60) "${totalMin / 60}h ${totalMin % 60}min" else "$totalMin min"
479
686
  tvDistance?.text = if (distanceRemainingMetres >= 1000)
480
687
  String.format("%.1f km", distanceRemainingMetres / 1000.0)
481
- else
482
- "${distanceRemainingMetres.toInt()} m"
688
+ else "${distanceRemainingMetres.toInt()} m"
483
689
  }
484
690
 
485
691
  // ─────────────────────────────────────────────────────────────────────────
486
- // Day / Night auto switch
692
+ // Day / Night
487
693
  // ─────────────────────────────────────────────────────────────────────────
488
694
  private fun getAutoStyle(): String {
489
695
  val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
@@ -496,9 +702,9 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
496
702
  val shouldBeNight = hour !in 6..20
497
703
  if (shouldBeNight == isNightMode) return
498
704
  isNightMode = shouldBeNight
499
- val newStyle = if (shouldBeNight) NavigationStyles.NAVIGATION_NIGHT_STYLE
500
- else NavigationStyles.NAVIGATION_DAY_STYLE
501
- mapView.mapboxMap.loadStyle(newStyle) { style ->
705
+ mapView.mapboxMap.loadStyle(
706
+ if (shouldBeNight) NavigationStyles.NAVIGATION_NIGHT_STYLE else NavigationStyles.NAVIGATION_DAY_STYLE
707
+ ) { style ->
502
708
  routeLineView.initializeLayers(style)
503
709
  mapboxNavigation?.getNavigationRoutes()?.takeIf { it.isNotEmpty() }?.let { routes ->
504
710
  routeLineApi.setNavigationRoutes(routes) { value ->
@@ -508,29 +714,23 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
508
714
  }
509
715
  }
510
716
 
511
- // ─────────────────────────────────────────────────────────────────────────
512
- // Camera following — safe wrapper (issue #43)
513
- // FIX: maxDuration(0) = instant transition, no animation delay
514
- // ─────────────────────────────────────────────────────────────────────────
515
717
  private fun safeCameraFollowing() {
516
718
  try {
517
719
  navigationCamera.requestNavigationCameraToFollowing(
518
- stateTransitionOptions = NavigationCameraTransitionOptions.Builder()
519
- .maxDuration(0)
520
- .build()
720
+ stateTransitionOptions = NavigationCameraTransitionOptions.Builder().maxDuration(0).build()
521
721
  )
522
722
  } catch (e: Exception) {
523
723
  Log.e(TAG, "Camera following error: ${e.message}")
524
724
  }
525
725
  }
526
726
 
527
- // ─────────────────────────────────────────────────────────────────────────
528
- // Cancel navigation — also stops foreground service (fixes notification crash)
529
- // ─────────────────────────────────────────────────────────────────────────
530
727
  private fun cancelNavigation() {
728
+ speechApi.cancel()
729
+ voiceInstructionsPlayer.clear()
531
730
  mapboxNavigation?.setNavigationRoutes(listOf())
532
731
  mapboxNavigation?.stopTripSession()
533
732
  firstLocationReceived = false
733
+ isOverviewMode = false
534
734
  hideUI()
535
735
  onNavigationCancelled(mapOf<String, Any>())
536
736
  }
@@ -538,17 +738,16 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
538
738
  private fun showUI() {
539
739
  maneuverView?.visibility = View.VISIBLE
540
740
  etaBar?.visibility = View.VISIBLE
741
+ sideButtons?.visibility = View.VISIBLE
541
742
  }
542
743
 
543
744
  private fun hideUI() {
544
745
  maneuverView?.visibility = View.INVISIBLE
545
746
  speedInfoView?.visibility = View.GONE
546
747
  etaBar?.visibility = View.INVISIBLE
748
+ sideButtons?.visibility = View.INVISIBLE
547
749
  }
548
750
 
549
- // ─────────────────────────────────────────────────────────────────────────
550
- // Issue #31 — Voice units
551
- // ─────────────────────────────────────────────────────────────────────────
552
751
  private fun resolveVoiceUnits(): String {
553
752
  return when (voiceUnits?.lowercase()) {
554
753
  "metric" -> "metric"
@@ -560,91 +759,58 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
560
759
  }
561
760
  }
562
761
 
563
- // ─────────────────────────────────────────────────────────────────────────
564
- // Route request
565
- // ─────────────────────────────────────────────────────────────────────────
566
762
  @SuppressLint("MissingPermission")
567
763
  private fun fetchRoutes() {
568
764
  val nav = mapboxNavigation ?: return
569
765
  if (coordinates.size < 2) return
570
-
571
- val points = coordinates.map {
572
- Point.fromLngLat(it["longitude"] ?: 0.0, it["latitude"] ?: 0.0)
573
- }
766
+ val points = coordinates.map { Point.fromLngLat(it["longitude"] ?: 0.0, it["latitude"] ?: 0.0) }
574
767
  val locale = language?.let { Locale.forLanguageTag(it) } ?: Locale.getDefault()
575
-
576
768
  val builder = RouteOptions.builder()
577
769
  .applyDefaultNavigationOptions()
578
770
  .language(locale.toLanguageTag())
579
771
  .voiceUnits(resolveVoiceUnits())
580
772
  .coordinatesList(points)
581
- // maxspeed annotation required for speed limit display
582
- .annotations("maxspeed,congestion,duration")
583
-
773
+ .annotations("maxspeed,congestion,duration,speed")
584
774
  waypointIndices?.let { builder.waypointIndicesList(it) }
585
- navigationProfile?.let {
586
- builder.profile(if (it.startsWith("mapbox/")) it else "mapbox/$it")
587
- }
588
- excludeTypes?.takeIf { it.isNotEmpty() }?.let {
589
- builder.exclude(it.joinToString(","))
590
- }
775
+ navigationProfile?.let { builder.profile(if (it.startsWith("mapbox/")) it else "mapbox/$it") }
776
+ excludeTypes?.takeIf { it.isNotEmpty() }?.let { builder.exclude(it.joinToString(",")) }
591
777
  maxHeight?.let { builder.maxHeight(it) }
592
778
  maxWidth?.let { builder.maxWidth(it) }
593
-
594
779
  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
602
- }
780
+ override fun onRoutesReady(routes: List<NavigationRoute>, @RouterOrigin routerOrigin: String) {
781
+ if (routes.isEmpty()) { onRoutesFailed(mapOf("message" to "No routes returned")); return }
603
782
  nav.setNavigationRoutes(routes)
604
783
  nav.startTripSession()
605
- // Camera will follow automatically via onNewLocationMatcherResult
606
- // + onRoutesChanged observer
607
784
  }
608
-
609
785
  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
- }
614
-
615
- override fun onCanceled(
616
- routeOptions: RouteOptions,
617
- @RouterOrigin routerOrigin: String
618
- ) {
619
- Log.d(TAG, "Route cancelled")
786
+ onRoutesFailed(mapOf("message" to (reasons.firstOrNull()?.message ?: "Unknown error")))
620
787
  }
788
+ override fun onCanceled(routeOptions: RouteOptions, @RouterOrigin routerOrigin: String) {}
621
789
  })
622
790
  }
623
791
 
624
- // ── Prop setters ──────────────────────────────────────────────────────────
625
- fun setCoordinates(coords: List<Map<String, Double>>) {
626
- coordinates = coords
627
- if (coords.size >= 2) fetchRoutes()
628
- }
792
+ fun setCoordinates(coords: List<Map<String, Double>>) { coordinates = coords; if (coords.size >= 2) fetchRoutes() }
629
793
  fun setWaypointIndices(i: List<Int>?) { waypointIndices = i }
630
794
  fun setLanguage(l: String?) { language = l }
631
795
  fun setVoiceUnits(u: String?) { voiceUnits = u }
632
796
  fun setNavigationProfile(p: String?) { navigationProfile = p }
633
797
  fun setExcludeTypes(t: List<String>?) { excludeTypes = t }
634
798
  fun setMapStyle(s: String?) { mapStyle = s }
635
- fun setMute(m: Boolean) { mute = m }
799
+ fun setMute(m: Boolean) { if (m != isMuted) toggleMute() }
636
800
  fun setMaxHeight(h: Double?) { maxHeight = h }
637
801
  fun setMaxWidth(w: Double?) { maxWidth = w }
638
802
  fun setUseMapMatching(u: Boolean) { useMapMatching = u }
639
803
  fun setCustomRasterTileUrl(u: String?) { customRasterTileUrl = u }
640
804
  fun setCustomRasterAboveLayerId(l: String?) { customRasterAboveLayerId = l }
641
805
 
642
- // ── Lifecycle ─────────────────────────────────────────────────────────────
643
806
  override fun onDetachedFromWindow() {
644
807
  super.onDetachedFromWindow()
808
+ speechApi.cancel()
809
+ voiceInstructionsPlayer.shutdown()
645
810
  maneuverApi.cancel()
646
811
  routeLineApi.cancel()
647
812
  routeLineView.cancel()
813
+ mapboxNavigation?.unregisterVoiceInstructionsObserver(voiceInstructionsObserver)
648
814
  mapboxNavigation?.stopTripSession()
649
815
  MapboxNavigationProvider.destroy()
650
816
  mapView.onStop()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacques_gordon/expo-mapbox-navigation",
3
- "version": "2.1.2",
3
+ "version": "2.1.4",
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",