@jacques_gordon/expo-mapbox-navigation 2.1.3 → 2.1.5

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,7 +3,15 @@ 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
7
15
  import android.graphics.drawable.GradientDrawable
8
16
  import android.util.Log
9
17
  import android.view.Gravity
@@ -19,10 +27,9 @@ import com.mapbox.maps.EdgeInsets
19
27
  import com.mapbox.maps.ImageHolder
20
28
  import com.mapbox.maps.MapView
21
29
  import com.mapbox.maps.plugin.LocationPuck2D
30
+ import com.mapbox.maps.plugin.PuckBearing
22
31
  import com.mapbox.maps.plugin.animation.camera
23
32
  import com.mapbox.maps.plugin.locationcomponent.location
24
- import com.mapbox.maps.plugin.locationcomponent.OnIndicatorBearingChangedListener
25
- import com.mapbox.maps.plugin.locationcomponent.OnIndicatorPositionChangedListener
26
33
  import com.mapbox.navigation.base.TimeFormat
27
34
  import com.mapbox.navigation.base.extensions.applyDefaultNavigationOptions
28
35
  import com.mapbox.navigation.base.formatter.DistanceFormatterOptions
@@ -39,6 +46,7 @@ import com.mapbox.navigation.core.formatter.MapboxDistanceFormatter
39
46
  import com.mapbox.navigation.core.trip.session.LocationMatcherResult
40
47
  import com.mapbox.navigation.core.trip.session.LocationObserver
41
48
  import com.mapbox.navigation.core.trip.session.RouteProgressObserver
49
+ import com.mapbox.navigation.core.trip.session.VoiceInstructionsObserver
42
50
  import com.mapbox.navigation.tripdata.maneuver.api.MapboxManeuverApi
43
51
  import com.mapbox.navigation.tripdata.progress.api.MapboxTripProgressApi
44
52
  import com.mapbox.navigation.tripdata.progress.model.DistanceRemainingFormatter
@@ -61,6 +69,12 @@ import com.mapbox.navigation.ui.maps.route.line.api.MapboxRouteLineApi
61
69
  import com.mapbox.navigation.ui.maps.route.line.api.MapboxRouteLineView
62
70
  import com.mapbox.navigation.ui.maps.route.line.model.MapboxRouteLineApiOptions
63
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
64
78
  import expo.modules.kotlin.AppContext
65
79
  import expo.modules.kotlin.viewevent.EventDispatcher
66
80
  import expo.modules.kotlin.views.ExpoView
@@ -89,11 +103,9 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
89
103
  private var tvDuration: TextView? = null
90
104
  private var tvDistance: TextView? = null
91
105
  private var etaBar: LinearLayout? = null
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
106
+ private var btnMuteView: ImageView? = null
107
+ private var btnOverviewView: ImageView? = null
108
+ private var btnRecenterView: ImageView? = null
97
109
  private var sideButtons: LinearLayout? = null
98
110
 
99
111
  // ── Navigation APIs ───────────────────────────────────────────────────────
@@ -109,6 +121,10 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
109
121
  private val navigationLocationProvider = NavigationLocationProvider()
110
122
  private var mapboxNavigation: MapboxNavigation? = null
111
123
 
124
+ // ── Voice APIs ────────────────────────────────────────────────────────────
125
+ private lateinit var speechApi: MapboxSpeechApi
126
+ private lateinit var voiceInstructionsPlayer: MapboxVoiceInstructionsPlayer
127
+
112
128
  // ── State ─────────────────────────────────────────────────────────────────
113
129
  private var isNightMode = false
114
130
  private var isMuted = false
@@ -118,25 +134,12 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
118
134
  // ── Pixel density ─────────────────────────────────────────────────────────
119
135
  private val dp = context.resources.displayMetrics.density
120
136
 
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
137
+ // ── Viewport padding ──────────────────────────────────────────────────────
125
138
  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
- )
139
+ EdgeInsets(180.0 * dp, 40.0 * dp, 300.0 * dp, 40.0 * dp)
132
140
  }
133
141
  private val overviewPadding by lazy {
134
- EdgeInsets(
135
- 120.0 * dp,
136
- 40.0 * dp,
137
- 120.0 * dp,
138
- 40.0 * dp
139
- )
142
+ EdgeInsets(120.0 * dp, 40.0 * dp, 120.0 * dp, 40.0 * dp)
140
143
  }
141
144
 
142
145
  // ── Props ─────────────────────────────────────────────────────────────────
@@ -161,7 +164,124 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
161
164
  }
162
165
 
163
166
  // ─────────────────────────────────────────────────────────────────────────
164
- // Build Waze-style UI
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
+
283
+ // ─────────────────────────────────────────────────────────────────────────
284
+ // Build UI
165
285
  // ─────────────────────────────────────────────────────────────────────────
166
286
  private fun buildUI() {
167
287
  val root = FrameLayout(context).apply {
@@ -174,7 +294,7 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
174
294
  FrameLayout.LayoutParams.MATCH_PARENT
175
295
  ))
176
296
 
177
- // ── ManeuverView — top banner ──────────────────────────────────────────
297
+ // ── ManeuverView — top ─────────────────────────────────────────────────
178
298
  val mv = MapboxManeuverView(context)
179
299
  root.addView(mv as View, FrameLayout.LayoutParams(
180
300
  FrameLayout.LayoutParams.MATCH_PARENT,
@@ -183,19 +303,8 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
183
303
  mv.visibility = View.INVISIBLE
184
304
  maneuverView = mv
185
305
 
186
- // ── SpeedInfoViewbottom-left, above ETA bar ────────────────────────
187
- val siv = MapboxSpeedInfoView(context)
188
- root.addView(siv as View, FrameLayout.LayoutParams(
189
- (72 * dp).toInt(),
190
- (72 * dp).toInt()
191
- ).also {
192
- it.gravity = Gravity.BOTTOM or Gravity.START
193
- it.setMargins((12 * dp).toInt(), 0, 0, (88 * dp).toInt())
194
- })
195
- siv.visibility = View.GONE
196
- speedInfoView = siv
197
-
198
- // ── Side action buttons (right side, Waze-style) ──────────────────────
306
+ // ── Side buttons RIGHT side, just below maneuver banner ─────────────
307
+ // Position: top margin = maneuver banner height (~180dp) + 8dp gap
199
308
  val sideCol = LinearLayout(context).apply {
200
309
  orientation = LinearLayout.VERTICAL
201
310
  gravity = Gravity.CENTER_HORIZONTAL
@@ -205,33 +314,45 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
205
314
  FrameLayout.LayoutParams.WRAP_CONTENT,
206
315
  FrameLayout.LayoutParams.WRAP_CONTENT
207
316
  ).also {
208
- it.gravity = Gravity.END or Gravity.BOTTOM
209
- it.setMargins(0, 0, (12 * dp).toInt(), (96 * dp).toInt())
317
+ it.gravity = Gravity.TOP or Gravity.END
318
+ it.setMargins(0, (188 * dp).toInt(), (12 * dp).toInt(), 0)
210
319
  })
211
320
  sideButtons = sideCol
212
321
 
213
- // Mute button
214
- val muteBtn = makeCircleButton("🔊")
215
- muteBtn.setOnClickListener { toggleMute() }
216
- sideCol.addView(muteBtn, circleButtonParams())
217
- btnMute = muteBtn
218
-
219
- sideCol.addView(View(context), LinearLayout.LayoutParams(1, (10 * dp).toInt()))
322
+ val btnSize = (56 * dp).toInt()
323
+ val btnParams = LinearLayout.LayoutParams(btnSize, btnSize).also {
324
+ it.bottomMargin = (10 * dp).toInt()
325
+ }
220
326
 
221
- // Overview button
222
- val overviewBtn = makeCircleButton("⊕")
223
- overviewBtn.setOnClickListener { toggleOverview() }
224
- sideCol.addView(overviewBtn, circleButtonParams())
225
- btnOverview = overviewBtn
327
+ // Button 1: Mute/Unmute (speaker icon)
328
+ val muteBtn = makeIconButton(drawSpeakerIcon(false)) { toggleMute() }
329
+ sideCol.addView(muteBtn, btnParams)
330
+ btnMuteView = muteBtn
226
331
 
227
- sideCol.addView(View(context), LinearLayout.LayoutParams(1, (10 * dp).toInt()))
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
228
338
 
229
- // Recenter button (shown only in overview mode)
230
- val recenterBtn = makeCircleButton("◎")
231
- recenterBtn.setOnClickListener { recenterCamera() }
339
+ // Button 3: Recenter / Navigation arrow (hidden until in overview mode)
340
+ val recenterBtn = makeIconButton(drawNavigationArrowIcon()) { recenterCamera() }
232
341
  recenterBtn.visibility = View.GONE
233
- sideCol.addView(recenterBtn, circleButtonParams())
234
- btnRecenter = recenterBtn
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()
350
+ ).also {
351
+ it.gravity = Gravity.BOTTOM or Gravity.START
352
+ it.setMargins((12 * dp).toInt(), 0, 0, (88 * dp).toInt())
353
+ })
354
+ siv.visibility = View.GONE
355
+ speedInfoView = siv
235
356
 
236
357
  // ── ETA bottom bar ─────────────────────────────────────────────────────
237
358
  val bar = LinearLayout(context).apply {
@@ -250,42 +371,29 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
250
371
  FrameLayout.LayoutParams.WRAP_CONTENT
251
372
  ).also { it.gravity = Gravity.BOTTOM })
252
373
 
253
- // ETA arrival time
254
374
  val etaTime = TextView(context).apply {
255
- textSize = 24f
256
- setTextColor(Color.WHITE)
375
+ textSize = 24f; setTextColor(Color.WHITE)
257
376
  typeface = android.graphics.Typeface.DEFAULT_BOLD
258
377
  }
259
378
  bar.addView(etaTime, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
260
379
  tvEtaTime = etaTime
261
380
 
262
- // Duration + distance (center)
263
381
  val center = LinearLayout(context).apply {
264
- orientation = LinearLayout.VERTICAL
265
- gravity = Gravity.CENTER
382
+ orientation = LinearLayout.VERTICAL; gravity = Gravity.CENTER
266
383
  }
267
384
  val dur = TextView(context).apply {
268
- textSize = 18f
269
- setTextColor(Color.WHITE)
270
- typeface = android.graphics.Typeface.DEFAULT_BOLD
271
- gravity = Gravity.CENTER
385
+ textSize = 18f; setTextColor(Color.WHITE)
386
+ typeface = android.graphics.Typeface.DEFAULT_BOLD; gravity = Gravity.CENTER
272
387
  }
273
388
  val dist = TextView(context).apply {
274
- textSize = 13f
275
- setTextColor(Color.parseColor("#AAAAAA"))
276
- gravity = Gravity.CENTER
389
+ textSize = 13f; setTextColor(Color.parseColor("#AAAAAA")); gravity = Gravity.CENTER
277
390
  }
278
- center.addView(dur)
279
- center.addView(dist)
391
+ center.addView(dur); center.addView(dist)
280
392
  bar.addView(center, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 2f))
281
- tvDuration = dur
282
- tvDistance = dist
393
+ tvDuration = dur; tvDistance = dist
283
394
 
284
- // Cancel button
285
395
  val cancelBtn = TextView(context).apply {
286
- text = "✕"
287
- textSize = 22f
288
- setTextColor(Color.parseColor("#AAAAAA"))
396
+ text = "✕"; textSize = 22f; setTextColor(Color.parseColor("#AAAAAA"))
289
397
  gravity = Gravity.END or Gravity.CENTER_VERTICAL
290
398
  setOnClickListener { cancelNavigation() }
291
399
  }
@@ -301,27 +409,6 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
301
409
  addView(root)
302
410
  }
303
411
 
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
-
325
412
  // ─────────────────────────────────────────────────────────────────────────
326
413
  // Init APIs
327
414
  // ─────────────────────────────────────────────────────────────────────────
@@ -350,6 +437,44 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
350
437
  )
351
438
 
352
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
+ // MapboxNavigationConsumer is in com.mapbox.navigation.ui.base.util (NOT base.route)
452
+ // voiceInstructionsPlayerCallback must be defined BEFORE speechCallback (used inside it)
453
+ private val voiceInstructionsPlayerCallback =
454
+ com.mapbox.navigation.ui.base.util.MapboxNavigationConsumer<SpeechAnnouncement> { value ->
455
+ speechApi.clean(value)
456
+ }
457
+
458
+ private val speechCallback =
459
+ com.mapbox.navigation.ui.base.util.MapboxNavigationConsumer<
460
+ com.mapbox.bindgen.Expected<SpeechError, SpeechValue>
461
+ > { expected ->
462
+ expected.fold(
463
+ { error ->
464
+ if (!isMuted) {
465
+ voiceInstructionsPlayer.play(error.fallback, voiceInstructionsPlayerCallback)
466
+ }
467
+ },
468
+ { value ->
469
+ if (!isMuted) {
470
+ voiceInstructionsPlayer.play(value.announcement, voiceInstructionsPlayerCallback)
471
+ }
472
+ }
473
+ )
474
+ }
475
+
476
+ private val voiceInstructionsObserver = VoiceInstructionsObserver { voiceInstructions ->
477
+ speechApi.generate(voiceInstructions, speechCallback)
353
478
  }
354
479
 
355
480
  // ─────────────────────────────────────────────────────────────────────────
@@ -363,7 +488,6 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
363
488
  mapView.mapboxMap.loadStyle(mapStyle ?: getAutoStyle()) { style ->
364
489
  routeLineView.initializeLayers(style)
365
490
 
366
- // Camera viewport
367
491
  viewportDataSource = MapboxNavigationViewportDataSource(mapView.mapboxMap)
368
492
  viewportDataSource.followingPadding = followingPadding
369
493
  viewportDataSource.overviewPadding = overviewPadding
@@ -374,24 +498,19 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
374
498
  viewportDataSource
375
499
  )
376
500
 
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
501
+ // Navigation arrow puck
380
502
  mapView.location.apply {
381
503
  setLocationProvider(navigationLocationProvider)
382
504
  updateSettings {
383
- // Built-in navigation arrow (chevron) from Nav SDK
384
505
  locationPuck = LocationPuck2D(
385
506
  bearingImage = ImageHolder.from(
386
507
  com.mapbox.navigation.R.drawable.mapbox_navigation_puck_icon
387
508
  )
388
509
  )
389
- // COURSE = direction of movement (not compass heading)
390
510
  puckBearingEnabled = true
391
511
  enabled = true
392
512
  }
393
- // Must set puckBearing separately — confirmed from migration guide
394
- puckBearing = com.mapbox.maps.plugin.PuckBearing.COURSE
513
+ puckBearing = PuckBearing.COURSE
395
514
  }
396
515
 
397
516
  registerObservers()
@@ -408,10 +527,12 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
408
527
  nav.registerRoutesObserver(object : RoutesObserver {
409
528
  override fun onRoutesChanged(result: RoutesUpdatedResult) {
410
529
  if (result.navigationRoutes.isNotEmpty()) {
530
+ // Cancel any in-flight speech when route changes
531
+ speechApi.cancel()
532
+ voiceInstructionsPlayer.clear()
533
+
411
534
  routeLineApi.setNavigationRoutes(result.navigationRoutes) { value ->
412
- mapView.mapboxMap.style?.apply {
413
- routeLineView.renderRouteDrawData(this, value)
414
- }
535
+ mapView.mapboxMap.style?.apply { routeLineView.renderRouteDrawData(this, value) }
415
536
  }
416
537
  viewportDataSource.onRouteChanged(result.navigationRoutes.first())
417
538
  viewportDataSource.evaluate()
@@ -442,13 +563,11 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
442
563
  viewportDataSource.onRouteProgressChanged(routeProgress)
443
564
  viewportDataSource.evaluate()
444
565
 
445
- // Maneuver arrow on map
446
566
  mapView.mapboxMap.style?.let { style ->
447
567
  val arrowResult = routeArrowApi.addUpcomingManeuverArrow(routeProgress)
448
568
  routeArrowView.renderManeuverUpdate(style, arrowResult)
449
569
  }
450
570
 
451
- // Maneuver banner
452
571
  val maneuvers = maneuverApi.getManeuvers(routeProgress)
453
572
  maneuvers.fold(
454
573
  { error -> Log.w(TAG, "Maneuver error: ${error.errorMessage}"); Unit },
@@ -459,7 +578,6 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
459
578
  }
460
579
  )
461
580
 
462
- // Trip progress
463
581
  val tripProgress = tripProgressApi.getTripProgress(routeProgress)
464
582
  tripProgressView?.render(tripProgress)
465
583
  updateEtaBar(
@@ -474,8 +592,7 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
474
592
  "distanceTraveled" to routeProgress.distanceTraveled,
475
593
  "fractionTraveled" to routeProgress.fractionTraveled,
476
594
  "currentStepDistanceRemaining" to
477
- (routeProgress.currentLegProgress
478
- ?.currentStepProgress?.distanceRemaining ?: 0f)
595
+ (routeProgress.currentLegProgress?.currentStepProgress?.distanceRemaining ?: 0f)
479
596
  ))
480
597
  })
481
598
 
@@ -484,22 +601,15 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
484
601
  override fun onNewRawLocation(rawLocation: Location) {}
485
602
  override fun onNewLocationMatcherResult(result: LocationMatcherResult) {
486
603
  val loc = result.enhancedLocation
487
-
488
- navigationLocationProvider.changePosition(
489
- location = loc,
490
- keyPoints = result.keyPoints,
491
- )
492
-
604
+ navigationLocationProvider.changePosition(location = loc, keyPoints = result.keyPoints)
493
605
  viewportDataSource.onLocationChanged(loc)
494
606
  viewportDataSource.evaluate()
495
607
 
496
- // First GPS fix → enter following mode
497
608
  if (!firstLocationReceived) {
498
609
  firstLocationReceived = true
499
610
  safeCameraFollowing()
500
611
  }
501
612
 
502
- // Speed limit display
503
613
  val fmtOptions = DistanceFormatterOptions.Builder(context).build()
504
614
  val speedInfo = speedInfoApi.updatePostedAndCurrentSpeed(result, fmtOptions)
505
615
  if (speedInfo != null) {
@@ -512,30 +622,28 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
512
622
  checkAndSwitchDayNight()
513
623
  }
514
624
  })
625
+
626
+ // Voice instructions observer — triggers TTS announcements
627
+ nav.registerVoiceInstructionsObserver(voiceInstructionsObserver)
515
628
  }
516
629
 
517
630
  // ─────────────────────────────────────────────────────────────────────────
518
631
  // Button actions
519
632
  // ─────────────────────────────────────────────────────────────────────────
520
-
521
- // Toggle mute voice guidance
522
633
  private fun toggleMute() {
523
634
  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))
635
+ // Update icon to show muted/unmuted state
636
+ btnMuteView?.setImageBitmap(drawSpeakerIcon(isMuted))
637
+ // Apply volume via SpeechVolume API
638
+ voiceInstructionsPlayer.volume(SpeechVolume(if (isMuted) 0f else 1f))
529
639
  }
530
640
 
531
- // Toggle overview / following mode
532
641
  private fun toggleOverview() {
533
642
  if (isOverviewMode) {
534
643
  recenterCamera()
535
644
  } else {
536
645
  isOverviewMode = true
537
- btnOverview?.text = "◉"
538
- btnRecenter?.visibility = View.VISIBLE
646
+ btnRecenterView?.visibility = View.VISIBLE
539
647
  try {
540
648
  navigationCamera.requestNavigationCameraToOverview()
541
649
  } catch (e: Exception) {
@@ -544,11 +652,9 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
544
652
  }
545
653
  }
546
654
 
547
- // Recenter camera to vehicle (following mode)
548
655
  private fun recenterCamera() {
549
656
  isOverviewMode = false
550
- btnOverview?.text = "⊕"
551
- btnRecenter?.visibility = View.GONE
657
+ btnRecenterView?.visibility = View.GONE
552
658
  safeCameraFollowing()
553
659
  }
554
660
 
@@ -560,25 +666,15 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
560
666
  totalTimeRemainingSec: Double,
561
667
  distanceRemainingMetres: Double
562
668
  ) {
563
- val arrivalCal = Calendar.getInstance().apply {
564
- timeInMillis = estimatedTimeToArrivalMs
565
- }
669
+ val arrivalCal = Calendar.getInstance().apply { timeInMillis = estimatedTimeToArrivalMs }
566
670
  tvEtaTime?.text = String.format(
567
- "%02d:%02d",
568
- arrivalCal.get(Calendar.HOUR_OF_DAY),
569
- arrivalCal.get(Calendar.MINUTE)
671
+ "%02d:%02d", arrivalCal.get(Calendar.HOUR_OF_DAY), arrivalCal.get(Calendar.MINUTE)
570
672
  )
571
-
572
673
  val totalMin = (totalTimeRemainingSec / 60).toInt()
573
- tvDuration?.text = if (totalMin >= 60)
574
- "${totalMin / 60}h ${totalMin % 60}min"
575
- else
576
- "${totalMin} min"
577
-
674
+ tvDuration?.text = if (totalMin >= 60) "${totalMin / 60}h ${totalMin % 60}min" else "$totalMin min"
578
675
  tvDistance?.text = if (distanceRemainingMetres >= 1000)
579
676
  String.format("%.1f km", distanceRemainingMetres / 1000.0)
580
- else
581
- "${distanceRemainingMetres.toInt()} m"
677
+ else "${distanceRemainingMetres.toInt()} m"
582
678
  }
583
679
 
584
680
  // ─────────────────────────────────────────────────────────────────────────
@@ -595,9 +691,9 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
595
691
  val shouldBeNight = hour !in 6..20
596
692
  if (shouldBeNight == isNightMode) return
597
693
  isNightMode = shouldBeNight
598
- val newStyle = if (shouldBeNight) NavigationStyles.NAVIGATION_NIGHT_STYLE
599
- else NavigationStyles.NAVIGATION_DAY_STYLE
600
- mapView.mapboxMap.loadStyle(newStyle) { style ->
694
+ mapView.mapboxMap.loadStyle(
695
+ if (shouldBeNight) NavigationStyles.NAVIGATION_NIGHT_STYLE else NavigationStyles.NAVIGATION_DAY_STYLE
696
+ ) { style ->
601
697
  routeLineView.initializeLayers(style)
602
698
  mapboxNavigation?.getNavigationRoutes()?.takeIf { it.isNotEmpty() }?.let { routes ->
603
699
  routeLineApi.setNavigationRoutes(routes) { value ->
@@ -607,25 +703,19 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
607
703
  }
608
704
  }
609
705
 
610
- // ─────────────────────────────────────────────────────────────────────────
611
- // Camera following — issue #43 safe wrapper
612
- // ─────────────────────────────────────────────────────────────────────────
613
706
  private fun safeCameraFollowing() {
614
707
  try {
615
708
  navigationCamera.requestNavigationCameraToFollowing(
616
- stateTransitionOptions = NavigationCameraTransitionOptions.Builder()
617
- .maxDuration(0)
618
- .build()
709
+ stateTransitionOptions = NavigationCameraTransitionOptions.Builder().maxDuration(0).build()
619
710
  )
620
711
  } catch (e: Exception) {
621
712
  Log.e(TAG, "Camera following error: ${e.message}")
622
713
  }
623
714
  }
624
715
 
625
- // ─────────────────────────────────────────────────────────────────────────
626
- // Cancel navigation
627
- // ─────────────────────────────────────────────────────────────────────────
628
716
  private fun cancelNavigation() {
717
+ speechApi.cancel()
718
+ voiceInstructionsPlayer.clear()
629
719
  mapboxNavigation?.setNavigationRoutes(listOf())
630
720
  mapboxNavigation?.stopTripSession()
631
721
  firstLocationReceived = false
@@ -647,9 +737,6 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
647
737
  sideButtons?.visibility = View.INVISIBLE
648
738
  }
649
739
 
650
- // ─────────────────────────────────────────────────────────────────────────
651
- // Issue #31 — Voice units
652
- // ─────────────────────────────────────────────────────────────────────────
653
740
  private fun resolveVoiceUnits(): String {
654
741
  return when (voiceUnits?.lowercase()) {
655
742
  "metric" -> "metric"
@@ -661,88 +748,58 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
661
748
  }
662
749
  }
663
750
 
664
- // ─────────────────────────────────────────────────────────────────────────
665
- // Route request
666
- // ─────────────────────────────────────────────────────────────────────────
667
751
  @SuppressLint("MissingPermission")
668
752
  private fun fetchRoutes() {
669
753
  val nav = mapboxNavigation ?: return
670
754
  if (coordinates.size < 2) return
671
-
672
- val points = coordinates.map {
673
- Point.fromLngLat(it["longitude"] ?: 0.0, it["latitude"] ?: 0.0)
674
- }
755
+ val points = coordinates.map { Point.fromLngLat(it["longitude"] ?: 0.0, it["latitude"] ?: 0.0) }
675
756
  val locale = language?.let { Locale.forLanguageTag(it) } ?: Locale.getDefault()
676
-
677
757
  val builder = RouteOptions.builder()
678
758
  .applyDefaultNavigationOptions()
679
759
  .language(locale.toLanguageTag())
680
760
  .voiceUnits(resolveVoiceUnits())
681
761
  .coordinatesList(points)
682
762
  .annotations("maxspeed,congestion,duration,speed")
683
-
684
763
  waypointIndices?.let { builder.waypointIndicesList(it) }
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
- }
764
+ navigationProfile?.let { builder.profile(if (it.startsWith("mapbox/")) it else "mapbox/$it") }
765
+ excludeTypes?.takeIf { it.isNotEmpty() }?.let { builder.exclude(it.joinToString(",")) }
691
766
  maxHeight?.let { builder.maxHeight(it) }
692
767
  maxWidth?.let { builder.maxWidth(it) }
693
-
694
768
  nav.requestRoutes(builder.build(), object : NavigationRouterCallback {
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
- }
769
+ override fun onRoutesReady(routes: List<NavigationRoute>, @RouterOrigin routerOrigin: String) {
770
+ if (routes.isEmpty()) { onRoutesFailed(mapOf("message" to "No routes returned")); return }
703
771
  nav.setNavigationRoutes(routes)
704
772
  nav.startTripSession()
705
773
  }
706
-
707
774
  override fun onFailure(reasons: List<RouterFailure>, routeOptions: RouteOptions) {
708
- val msg = reasons.firstOrNull()?.message ?: "Unknown error"
709
- Log.e(TAG, "Route failed: $msg")
710
- onRoutesFailed(mapOf("message" to msg))
711
- }
712
-
713
- override fun onCanceled(
714
- routeOptions: RouteOptions,
715
- @RouterOrigin routerOrigin: String
716
- ) {
717
- Log.d(TAG, "Route cancelled")
775
+ onRoutesFailed(mapOf("message" to (reasons.firstOrNull()?.message ?: "Unknown error")))
718
776
  }
777
+ override fun onCanceled(routeOptions: RouteOptions, @RouterOrigin routerOrigin: String) {}
719
778
  })
720
779
  }
721
780
 
722
- // ── Prop setters ──────────────────────────────────────────────────────────
723
- fun setCoordinates(coords: List<Map<String, Double>>) {
724
- coordinates = coords
725
- if (coords.size >= 2) fetchRoutes()
726
- }
781
+ fun setCoordinates(coords: List<Map<String, Double>>) { coordinates = coords; if (coords.size >= 2) fetchRoutes() }
727
782
  fun setWaypointIndices(i: List<Int>?) { waypointIndices = i }
728
783
  fun setLanguage(l: String?) { language = l }
729
784
  fun setVoiceUnits(u: String?) { voiceUnits = u }
730
785
  fun setNavigationProfile(p: String?) { navigationProfile = p }
731
786
  fun setExcludeTypes(t: List<String>?) { excludeTypes = t }
732
787
  fun setMapStyle(s: String?) { mapStyle = s }
733
- fun setMute(m: Boolean) { mute = m; if (m != isMuted) toggleMute() }
788
+ fun setMute(m: Boolean) { if (m != isMuted) toggleMute() }
734
789
  fun setMaxHeight(h: Double?) { maxHeight = h }
735
790
  fun setMaxWidth(w: Double?) { maxWidth = w }
736
791
  fun setUseMapMatching(u: Boolean) { useMapMatching = u }
737
792
  fun setCustomRasterTileUrl(u: String?) { customRasterTileUrl = u }
738
793
  fun setCustomRasterAboveLayerId(l: String?) { customRasterAboveLayerId = l }
739
794
 
740
- // ── Lifecycle ─────────────────────────────────────────────────────────────
741
795
  override fun onDetachedFromWindow() {
742
796
  super.onDetachedFromWindow()
797
+ speechApi.cancel()
798
+ voiceInstructionsPlayer.shutdown()
743
799
  maneuverApi.cancel()
744
800
  routeLineApi.cancel()
745
801
  routeLineView.cancel()
802
+ mapboxNavigation?.unregisterVoiceInstructionsObserver(voiceInstructionsObserver)
746
803
  mapboxNavigation?.stopTripSession()
747
804
  MapboxNavigationProvider.destroy()
748
805
  mapView.onStop()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacques_gordon/expo-mapbox-navigation",
3
- "version": "2.1.3",
3
+ "version": "2.1.5",
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",