@jacques_gordon/expo-mapbox-navigation 2.1.3 → 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,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,55 @@ 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
+ // 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)
353
489
  }
354
490
 
355
491
  // ─────────────────────────────────────────────────────────────────────────
@@ -363,7 +499,6 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
363
499
  mapView.mapboxMap.loadStyle(mapStyle ?: getAutoStyle()) { style ->
364
500
  routeLineView.initializeLayers(style)
365
501
 
366
- // Camera viewport
367
502
  viewportDataSource = MapboxNavigationViewportDataSource(mapView.mapboxMap)
368
503
  viewportDataSource.followingPadding = followingPadding
369
504
  viewportDataSource.overviewPadding = overviewPadding
@@ -374,24 +509,19 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
374
509
  viewportDataSource
375
510
  )
376
511
 
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
512
+ // Navigation arrow puck
380
513
  mapView.location.apply {
381
514
  setLocationProvider(navigationLocationProvider)
382
515
  updateSettings {
383
- // Built-in navigation arrow (chevron) from Nav SDK
384
516
  locationPuck = LocationPuck2D(
385
517
  bearingImage = ImageHolder.from(
386
518
  com.mapbox.navigation.R.drawable.mapbox_navigation_puck_icon
387
519
  )
388
520
  )
389
- // COURSE = direction of movement (not compass heading)
390
521
  puckBearingEnabled = true
391
522
  enabled = true
392
523
  }
393
- // Must set puckBearing separately — confirmed from migration guide
394
- puckBearing = com.mapbox.maps.plugin.PuckBearing.COURSE
524
+ puckBearing = PuckBearing.COURSE
395
525
  }
396
526
 
397
527
  registerObservers()
@@ -408,10 +538,12 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
408
538
  nav.registerRoutesObserver(object : RoutesObserver {
409
539
  override fun onRoutesChanged(result: RoutesUpdatedResult) {
410
540
  if (result.navigationRoutes.isNotEmpty()) {
541
+ // Cancel any in-flight speech when route changes
542
+ speechApi.cancel()
543
+ voiceInstructionsPlayer.clear()
544
+
411
545
  routeLineApi.setNavigationRoutes(result.navigationRoutes) { value ->
412
- mapView.mapboxMap.style?.apply {
413
- routeLineView.renderRouteDrawData(this, value)
414
- }
546
+ mapView.mapboxMap.style?.apply { routeLineView.renderRouteDrawData(this, value) }
415
547
  }
416
548
  viewportDataSource.onRouteChanged(result.navigationRoutes.first())
417
549
  viewportDataSource.evaluate()
@@ -442,13 +574,11 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
442
574
  viewportDataSource.onRouteProgressChanged(routeProgress)
443
575
  viewportDataSource.evaluate()
444
576
 
445
- // Maneuver arrow on map
446
577
  mapView.mapboxMap.style?.let { style ->
447
578
  val arrowResult = routeArrowApi.addUpcomingManeuverArrow(routeProgress)
448
579
  routeArrowView.renderManeuverUpdate(style, arrowResult)
449
580
  }
450
581
 
451
- // Maneuver banner
452
582
  val maneuvers = maneuverApi.getManeuvers(routeProgress)
453
583
  maneuvers.fold(
454
584
  { error -> Log.w(TAG, "Maneuver error: ${error.errorMessage}"); Unit },
@@ -459,7 +589,6 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
459
589
  }
460
590
  )
461
591
 
462
- // Trip progress
463
592
  val tripProgress = tripProgressApi.getTripProgress(routeProgress)
464
593
  tripProgressView?.render(tripProgress)
465
594
  updateEtaBar(
@@ -474,8 +603,7 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
474
603
  "distanceTraveled" to routeProgress.distanceTraveled,
475
604
  "fractionTraveled" to routeProgress.fractionTraveled,
476
605
  "currentStepDistanceRemaining" to
477
- (routeProgress.currentLegProgress
478
- ?.currentStepProgress?.distanceRemaining ?: 0f)
606
+ (routeProgress.currentLegProgress?.currentStepProgress?.distanceRemaining ?: 0f)
479
607
  ))
480
608
  })
481
609
 
@@ -484,22 +612,15 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
484
612
  override fun onNewRawLocation(rawLocation: Location) {}
485
613
  override fun onNewLocationMatcherResult(result: LocationMatcherResult) {
486
614
  val loc = result.enhancedLocation
487
-
488
- navigationLocationProvider.changePosition(
489
- location = loc,
490
- keyPoints = result.keyPoints,
491
- )
492
-
615
+ navigationLocationProvider.changePosition(location = loc, keyPoints = result.keyPoints)
493
616
  viewportDataSource.onLocationChanged(loc)
494
617
  viewportDataSource.evaluate()
495
618
 
496
- // First GPS fix → enter following mode
497
619
  if (!firstLocationReceived) {
498
620
  firstLocationReceived = true
499
621
  safeCameraFollowing()
500
622
  }
501
623
 
502
- // Speed limit display
503
624
  val fmtOptions = DistanceFormatterOptions.Builder(context).build()
504
625
  val speedInfo = speedInfoApi.updatePostedAndCurrentSpeed(result, fmtOptions)
505
626
  if (speedInfo != null) {
@@ -512,30 +633,28 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
512
633
  checkAndSwitchDayNight()
513
634
  }
514
635
  })
636
+
637
+ // Voice instructions observer — triggers TTS announcements
638
+ nav.registerVoiceInstructionsObserver(voiceInstructionsObserver)
515
639
  }
516
640
 
517
641
  // ─────────────────────────────────────────────────────────────────────────
518
642
  // Button actions
519
643
  // ─────────────────────────────────────────────────────────────────────────
520
-
521
- // Toggle mute voice guidance
522
644
  private fun toggleMute() {
523
645
  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))
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))
529
650
  }
530
651
 
531
- // Toggle overview / following mode
532
652
  private fun toggleOverview() {
533
653
  if (isOverviewMode) {
534
654
  recenterCamera()
535
655
  } else {
536
656
  isOverviewMode = true
537
- btnOverview?.text = "◉"
538
- btnRecenter?.visibility = View.VISIBLE
657
+ btnRecenterView?.visibility = View.VISIBLE
539
658
  try {
540
659
  navigationCamera.requestNavigationCameraToOverview()
541
660
  } catch (e: Exception) {
@@ -544,11 +663,9 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
544
663
  }
545
664
  }
546
665
 
547
- // Recenter camera to vehicle (following mode)
548
666
  private fun recenterCamera() {
549
667
  isOverviewMode = false
550
- btnOverview?.text = "⊕"
551
- btnRecenter?.visibility = View.GONE
668
+ btnRecenterView?.visibility = View.GONE
552
669
  safeCameraFollowing()
553
670
  }
554
671
 
@@ -560,25 +677,15 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
560
677
  totalTimeRemainingSec: Double,
561
678
  distanceRemainingMetres: Double
562
679
  ) {
563
- val arrivalCal = Calendar.getInstance().apply {
564
- timeInMillis = estimatedTimeToArrivalMs
565
- }
680
+ val arrivalCal = Calendar.getInstance().apply { timeInMillis = estimatedTimeToArrivalMs }
566
681
  tvEtaTime?.text = String.format(
567
- "%02d:%02d",
568
- arrivalCal.get(Calendar.HOUR_OF_DAY),
569
- arrivalCal.get(Calendar.MINUTE)
682
+ "%02d:%02d", arrivalCal.get(Calendar.HOUR_OF_DAY), arrivalCal.get(Calendar.MINUTE)
570
683
  )
571
-
572
684
  val totalMin = (totalTimeRemainingSec / 60).toInt()
573
- tvDuration?.text = if (totalMin >= 60)
574
- "${totalMin / 60}h ${totalMin % 60}min"
575
- else
576
- "${totalMin} min"
577
-
685
+ tvDuration?.text = if (totalMin >= 60) "${totalMin / 60}h ${totalMin % 60}min" else "$totalMin min"
578
686
  tvDistance?.text = if (distanceRemainingMetres >= 1000)
579
687
  String.format("%.1f km", distanceRemainingMetres / 1000.0)
580
- else
581
- "${distanceRemainingMetres.toInt()} m"
688
+ else "${distanceRemainingMetres.toInt()} m"
582
689
  }
583
690
 
584
691
  // ─────────────────────────────────────────────────────────────────────────
@@ -595,9 +702,9 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
595
702
  val shouldBeNight = hour !in 6..20
596
703
  if (shouldBeNight == isNightMode) return
597
704
  isNightMode = shouldBeNight
598
- val newStyle = if (shouldBeNight) NavigationStyles.NAVIGATION_NIGHT_STYLE
599
- else NavigationStyles.NAVIGATION_DAY_STYLE
600
- mapView.mapboxMap.loadStyle(newStyle) { style ->
705
+ mapView.mapboxMap.loadStyle(
706
+ if (shouldBeNight) NavigationStyles.NAVIGATION_NIGHT_STYLE else NavigationStyles.NAVIGATION_DAY_STYLE
707
+ ) { style ->
601
708
  routeLineView.initializeLayers(style)
602
709
  mapboxNavigation?.getNavigationRoutes()?.takeIf { it.isNotEmpty() }?.let { routes ->
603
710
  routeLineApi.setNavigationRoutes(routes) { value ->
@@ -607,25 +714,19 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
607
714
  }
608
715
  }
609
716
 
610
- // ─────────────────────────────────────────────────────────────────────────
611
- // Camera following — issue #43 safe wrapper
612
- // ─────────────────────────────────────────────────────────────────────────
613
717
  private fun safeCameraFollowing() {
614
718
  try {
615
719
  navigationCamera.requestNavigationCameraToFollowing(
616
- stateTransitionOptions = NavigationCameraTransitionOptions.Builder()
617
- .maxDuration(0)
618
- .build()
720
+ stateTransitionOptions = NavigationCameraTransitionOptions.Builder().maxDuration(0).build()
619
721
  )
620
722
  } catch (e: Exception) {
621
723
  Log.e(TAG, "Camera following error: ${e.message}")
622
724
  }
623
725
  }
624
726
 
625
- // ─────────────────────────────────────────────────────────────────────────
626
- // Cancel navigation
627
- // ─────────────────────────────────────────────────────────────────────────
628
727
  private fun cancelNavigation() {
728
+ speechApi.cancel()
729
+ voiceInstructionsPlayer.clear()
629
730
  mapboxNavigation?.setNavigationRoutes(listOf())
630
731
  mapboxNavigation?.stopTripSession()
631
732
  firstLocationReceived = false
@@ -647,9 +748,6 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
647
748
  sideButtons?.visibility = View.INVISIBLE
648
749
  }
649
750
 
650
- // ─────────────────────────────────────────────────────────────────────────
651
- // Issue #31 — Voice units
652
- // ─────────────────────────────────────────────────────────────────────────
653
751
  private fun resolveVoiceUnits(): String {
654
752
  return when (voiceUnits?.lowercase()) {
655
753
  "metric" -> "metric"
@@ -661,88 +759,58 @@ class ExpoMapboxNavigationView(context: Context, appContext: AppContext) :
661
759
  }
662
760
  }
663
761
 
664
- // ─────────────────────────────────────────────────────────────────────────
665
- // Route request
666
- // ─────────────────────────────────────────────────────────────────────────
667
762
  @SuppressLint("MissingPermission")
668
763
  private fun fetchRoutes() {
669
764
  val nav = mapboxNavigation ?: return
670
765
  if (coordinates.size < 2) return
671
-
672
- val points = coordinates.map {
673
- Point.fromLngLat(it["longitude"] ?: 0.0, it["latitude"] ?: 0.0)
674
- }
766
+ val points = coordinates.map { Point.fromLngLat(it["longitude"] ?: 0.0, it["latitude"] ?: 0.0) }
675
767
  val locale = language?.let { Locale.forLanguageTag(it) } ?: Locale.getDefault()
676
-
677
768
  val builder = RouteOptions.builder()
678
769
  .applyDefaultNavigationOptions()
679
770
  .language(locale.toLanguageTag())
680
771
  .voiceUnits(resolveVoiceUnits())
681
772
  .coordinatesList(points)
682
773
  .annotations("maxspeed,congestion,duration,speed")
683
-
684
774
  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
- }
775
+ navigationProfile?.let { builder.profile(if (it.startsWith("mapbox/")) it else "mapbox/$it") }
776
+ excludeTypes?.takeIf { it.isNotEmpty() }?.let { builder.exclude(it.joinToString(",")) }
691
777
  maxHeight?.let { builder.maxHeight(it) }
692
778
  maxWidth?.let { builder.maxWidth(it) }
693
-
694
779
  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
- }
780
+ override fun onRoutesReady(routes: List<NavigationRoute>, @RouterOrigin routerOrigin: String) {
781
+ if (routes.isEmpty()) { onRoutesFailed(mapOf("message" to "No routes returned")); return }
703
782
  nav.setNavigationRoutes(routes)
704
783
  nav.startTripSession()
705
784
  }
706
-
707
785
  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")
786
+ onRoutesFailed(mapOf("message" to (reasons.firstOrNull()?.message ?: "Unknown error")))
718
787
  }
788
+ override fun onCanceled(routeOptions: RouteOptions, @RouterOrigin routerOrigin: String) {}
719
789
  })
720
790
  }
721
791
 
722
- // ── Prop setters ──────────────────────────────────────────────────────────
723
- fun setCoordinates(coords: List<Map<String, Double>>) {
724
- coordinates = coords
725
- if (coords.size >= 2) fetchRoutes()
726
- }
792
+ fun setCoordinates(coords: List<Map<String, Double>>) { coordinates = coords; if (coords.size >= 2) fetchRoutes() }
727
793
  fun setWaypointIndices(i: List<Int>?) { waypointIndices = i }
728
794
  fun setLanguage(l: String?) { language = l }
729
795
  fun setVoiceUnits(u: String?) { voiceUnits = u }
730
796
  fun setNavigationProfile(p: String?) { navigationProfile = p }
731
797
  fun setExcludeTypes(t: List<String>?) { excludeTypes = t }
732
798
  fun setMapStyle(s: String?) { mapStyle = s }
733
- fun setMute(m: Boolean) { mute = m; if (m != isMuted) toggleMute() }
799
+ fun setMute(m: Boolean) { if (m != isMuted) toggleMute() }
734
800
  fun setMaxHeight(h: Double?) { maxHeight = h }
735
801
  fun setMaxWidth(w: Double?) { maxWidth = w }
736
802
  fun setUseMapMatching(u: Boolean) { useMapMatching = u }
737
803
  fun setCustomRasterTileUrl(u: String?) { customRasterTileUrl = u }
738
804
  fun setCustomRasterAboveLayerId(l: String?) { customRasterAboveLayerId = l }
739
805
 
740
- // ── Lifecycle ─────────────────────────────────────────────────────────────
741
806
  override fun onDetachedFromWindow() {
742
807
  super.onDetachedFromWindow()
808
+ speechApi.cancel()
809
+ voiceInstructionsPlayer.shutdown()
743
810
  maneuverApi.cancel()
744
811
  routeLineApi.cancel()
745
812
  routeLineView.cancel()
813
+ mapboxNavigation?.unregisterVoiceInstructionsObserver(voiceInstructionsObserver)
746
814
  mapboxNavigation?.stopTripSession()
747
815
  MapboxNavigationProvider.destroy()
748
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.3",
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",