Merge pull request 'feat: search engine' (#7) from feat/issue.3.search into master

Reviewed-on: https://hattori.ztsh.eu/stawros/Garmin/pulls/7
This commit is contained in:
Piotr Dec 2024-08-09 01:34:23 +02:00
commit fb62c8dd77
11 changed files with 445 additions and 7 deletions

View file

@ -40,14 +40,27 @@ android {
}
ext {
mapboxVersion = '3.2.0'
searchApiVersion = '2.3.1'
searchNativeVersion = '2.2.1'
}
dependencies {
implementation 'com.mapbox.maps:android:11.5.1'
implementation "com.mapbox.navigationcore:navigation:$mapboxVersion"
implementation "com.mapbox.navigationcore:ui-maps:$mapboxVersion"
implementation "com.mapbox.navigationcore:voice:$mapboxVersion"
implementation "com.mapbox.navigationcore:tripdata:$mapboxVersion"
implementation "com.mapbox.navigationcore:ui-components:$mapboxVersion"
implementation "com.mapbox.search:base:$searchApiVersion"
implementation "com.mapbox.search:autofill:$searchApiVersion"
implementation "com.mapbox.search:place-autocomplete:$searchApiVersion"
implementation "com.mapbox.search:mapbox-search-android:$searchApiVersion"
implementation "com.mapbox.search:mapbox-search-android-ui:$searchApiVersion"
implementation "com.mapbox.search:mapbox-search-android-native:$searchNativeVersion"
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'com.google.android.material:material:1.12.0'

View file

@ -27,6 +27,7 @@ import eu.ztsh.garmin.mapbox.MapControl
import eu.ztsh.garmin.util.PermissionsHelper
import java.lang.ref.WeakReference
@SuppressLint("MissingPermission")
class MainActivity : AppCompatActivity() {

View file

@ -56,4 +56,7 @@ class UI(b: ActivityMainBinding) {
val routeOverview = b.routeOverview
val stop = b.stop
val searchResultsView = b.searchResults
val searchPlaceView = b.searchPlaces
val queryEditText = b.query
}

View file

@ -4,6 +4,7 @@ import android.content.res.Configuration
import android.content.res.Resources
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import com.mapbox.geojson.Point
import com.mapbox.maps.ImageHolder
import com.mapbox.maps.plugin.LocationPuck2D
import com.mapbox.maps.plugin.animation.camera
@ -48,6 +49,8 @@ class MapControl(
*/
val navigationLocationProvider = NavigationLocationProvider()
val navigationStatusControl = NavigationStatusControl()
val replay = ReplayResources(this)
// Observers
@ -58,6 +61,7 @@ class MapControl(
private lateinit var locationObserver: LocationObserver
private lateinit var routeProgressObserver: RouteProgressObserver
private lateinit var voiceInstructionsObserver: VoiceInstructionsObserver
private val searchControl = SearchControl(this, ui)
fun init() {
viewportDataSource = MapboxNavigationViewportDataSource(ui.mapView.mapboxMap)
@ -103,6 +107,10 @@ class MapControl(
voiceInstructionsObserver = voiceControl.voiceInstructionsObserver
}
fun routeToPoint(point: Point) {
routeControl.findRoute(point)
}
fun initNavigation() {
MapboxNavigationApp.setup(
NavigationOptions.Builder(context)
@ -134,6 +142,8 @@ class MapControl(
mapboxNavigation.registerRouteProgressObserver(routeProgressObserver)
mapboxNavigation.registerVoiceInstructionsObserver(voiceInstructionsObserver)
navigationStatusControl.registerObserver(searchControl)
replay.onAttached(mapboxNavigation)
}
@ -142,6 +152,9 @@ class MapControl(
mapboxNavigation.unregisterLocationObserver(locationObserver)
mapboxNavigation.unregisterRouteProgressObserver(routeProgressObserver)
mapboxNavigation.unregisterVoiceInstructionsObserver(voiceInstructionsObserver)
navigationStatusControl.unregisterObserver(searchControl)
replay.onDetached(mapboxNavigation)
}

View file

@ -0,0 +1,49 @@
package eu.ztsh.garmin.mapbox
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import java.util.concurrent.CopyOnWriteArraySet
import java.util.concurrent.atomic.AtomicReference
class NavigationStatusControl {
private val stateObservers = CopyOnWriteArraySet<NavigationStatusObserver>()
private val current = AtomicReference(NavigationStatus.IDLE)
fun registerObserver(observer: NavigationStatusObserver) {
stateObservers.add(observer)
observer.onNavigationStatusChanged(current.get())
}
fun unregisterObserver(observer: NavigationStatusObserver) {
stateObservers.remove(observer)
}
fun sendEvent(status: NavigationStatus) {
current.set(status)
stateObservers.forEach {
it.onNavigationStatusChanged(status)
}
if (status == NavigationStatus.FINISHED || status == NavigationStatus.CANCELED) {
// TODO: lifecyclescope?
runBlocking {
delay(1000)
sendEvent(NavigationStatus.IDLE)
}
}
}
}
enum class NavigationStatus {
IDLE,
STARTED,
FINISHED,
CANCELED
}
fun interface NavigationStatusObserver {
fun onNavigationStatusChanged(navigationStatus: NavigationStatus)
}

View file

@ -3,10 +3,10 @@ package eu.ztsh.garmin.mapbox
import android.content.Context
import android.view.View
import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import com.mapbox.api.directions.v5.models.Bearing
import com.mapbox.api.directions.v5.models.RouteOptions
import com.mapbox.geojson.Point
import com.mapbox.maps.plugin.gestures.gestures
import com.mapbox.navigation.base.TimeFormat
import com.mapbox.navigation.base.extensions.applyDefaultNavigationOptions
import com.mapbox.navigation.base.extensions.applyLanguageAndVoiceUnitOptions
@ -37,6 +37,9 @@ import com.mapbox.navigation.ui.maps.route.line.model.MapboxRouteLineApiOptions
import com.mapbox.navigation.ui.maps.route.line.model.MapboxRouteLineViewOptions
import eu.ztsh.garmin.Garmin
import eu.ztsh.garmin.UI
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class RouteControl(private val mapControl: MapControl, ui: UI, private val context: Context) {
@ -115,11 +118,6 @@ class RouteControl(private val mapControl: MapControl, ui: UI, private val conte
// Ensure that the route line related layers are present before the route arrow
routeLineView.initializeLayers(it)
// add long click listener that search for a route to the clicked destination
ui.mapView.gestures.addOnMapLongClickListener { point ->
findRoute(point)
true
}
}
// initialize view interactions
@ -219,7 +217,7 @@ class RouteControl(private val mapControl: MapControl, ui: UI, private val conte
}
}
private fun findRoute(destination: Point) {
fun findRoute(destination: Point) {
val originLocation = mapControl.navigationLocationProvider.lastLocation ?: return
val originPoint = Point.fromLngLat(originLocation.longitude, originLocation.latitude)
@ -284,6 +282,15 @@ class RouteControl(private val mapControl: MapControl, ui: UI, private val conte
// start simulation
mapControl.replay.startSimulation(routes.first().directionsRoute)
mapControl.context.apply {
lifecycleScope.launch {
async {
delay(5000)
mapControl.navigationCamera.requestNavigationCameraToFollowing()
}
}
}
}
private fun clearRouteAndStopNavigation() {
@ -298,6 +305,9 @@ class RouteControl(private val mapControl: MapControl, ui: UI, private val conte
mapControl.ui.maneuverView.visibility = View.INVISIBLE
mapControl.ui.routeOverview.visibility = View.INVISIBLE
mapControl.ui.tripProgressCard.visibility = View.INVISIBLE
// post custom event
mapControl.navigationStatusControl.sendEvent(NavigationStatus.CANCELED)
}
fun cancel() {

View file

@ -0,0 +1,281 @@
package eu.ztsh.garmin.mapbox
import android.content.Context
import android.content.res.Resources
import android.text.Editable
import android.text.TextWatcher
import android.util.Log
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.mapbox.geojson.Point
import com.mapbox.maps.CameraOptions
import com.mapbox.maps.EdgeInsets
import com.mapbox.maps.MapView
import com.mapbox.maps.plugin.annotation.annotations
import com.mapbox.maps.plugin.annotation.generated.CircleAnnotationOptions
import com.mapbox.maps.plugin.annotation.generated.createCircleAnnotationManager
import com.mapbox.maps.plugin.gestures.gestures
import com.mapbox.search.autocomplete.PlaceAutocomplete
import com.mapbox.search.autocomplete.PlaceAutocompleteOptions
import com.mapbox.search.autocomplete.PlaceAutocompleteSuggestion
import com.mapbox.search.autocomplete.PlaceAutocompleteType
import com.mapbox.search.ui.adapter.autocomplete.PlaceAutocompleteUiAdapter
import com.mapbox.search.ui.view.CommonSearchViewConfiguration
import com.mapbox.search.ui.view.SearchResultsView
import com.mapbox.search.ui.view.place.SearchPlace
import eu.ztsh.garmin.R
import eu.ztsh.garmin.UI
import kotlinx.coroutines.launch
class SearchControl(val mapControl: MapControl, val ui: UI): NavigationStatusObserver {
// Set your Access Token here if it's not already set in some other way
// MapboxOptions.accessToken = "<my-access-token>"
private var placeAutocomplete: PlaceAutocomplete = PlaceAutocomplete.create()
private var placeAutocompleteUiAdapter: PlaceAutocompleteUiAdapter
private val mapMarkersManager: MapMarkersManager = MapMarkersManager(ui.mapView)
private var ignoreNextQueryUpdate = false
init {
ui.searchResultsView.initialize(
SearchResultsView.Configuration(
commonConfiguration = CommonSearchViewConfiguration()
)
)
placeAutocompleteUiAdapter = PlaceAutocompleteUiAdapter(
view = ui.searchResultsView,
placeAutocomplete = placeAutocomplete
)
placeAutocompleteUiAdapter.addSearchListener(object : PlaceAutocompleteUiAdapter.SearchListener {
override fun onSuggestionsShown(suggestions: List<PlaceAutocompleteSuggestion>) {
// Nothing to do
}
override fun onSuggestionSelected(suggestion: PlaceAutocompleteSuggestion) {
openPlaceCard(suggestion)
}
override fun onPopulateQueryClick(suggestion: PlaceAutocompleteSuggestion) {
ui.queryEditText.setText(suggestion.name)
}
override fun onError(e: Exception) {
// Nothing to do
}
})
ui.searchPlaceView.apply {
initialize(CommonSearchViewConfiguration())
isFavoriteButtonVisible = false
addOnCloseClickListener {
hide()
closePlaceCard()
}
addOnNavigateClickListener { searchPlace ->
mapControl.routeToPoint(searchPlace.coordinate)
mapControl.navigationStatusControl.sendEvent(NavigationStatus.STARTED)
}
addOnShareClickListener { _ ->
showToast(R.string.not_implemented_yet)
}
}
ui.queryEditText.addTextChangedListener(object : TextWatcher {
override fun onTextChanged(text: CharSequence, start: Int, before: Int, count: Int) {
if (ignoreNextQueryUpdate) {
ignoreNextQueryUpdate = false
} else {
closePlaceCard()
}
mapControl.context.apply {
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
placeAutocompleteUiAdapter.search(text.toString())
ui.searchResultsView.isVisible = text.isNotEmpty()
}
}
}
}
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {
// Nothing to do
}
override fun afterTextChanged(s: Editable) {
// Nothing to do
}
})
ui.mapView.gestures.addOnMapLongClickListener {
reverseGeocoding(it)
return@addOnMapLongClickListener true
}
}
private fun openPlaceCard(suggestion: PlaceAutocompleteSuggestion) {
ignoreNextQueryUpdate = true
ui.queryEditText.setText("")
mapControl.context.apply {
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
placeAutocomplete.select(suggestion).onValue { result ->
mapMarkersManager.showMarker(result.coordinate)
ui.searchPlaceView.open(SearchPlace.createFromPlaceAutocompleteResult(result))
ui.queryEditText.hideKeyboard()
ui.searchResultsView.isVisible = false
}.onError { error ->
Log.d(LOG_TAG, "Suggestion selection error", error)
showToast(R.string.place_autocomplete_selection_error)
}
}
}
}
}
private fun closePlaceCard() {
ui.searchPlaceView.hide()
mapMarkersManager.clearMarkers()
}
private fun reverseGeocoding(point: Point) {
val types: List<PlaceAutocompleteType> = when (ui.mapView.mapboxMap.cameraState.zoom) {
in 0.0..4.0 -> REGION_LEVEL_TYPES
in 4.0..6.0 -> DISTRICT_LEVEL_TYPES
in 6.0..12.0 -> LOCALITY_LEVEL_TYPES
else -> ALL_TYPES
}
mapControl.context.lifecycleScope.launch {
mapControl.context.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
val response = placeAutocomplete.reverse(point, PlaceAutocompleteOptions(types = types))
response.onValue { suggestions ->
if (suggestions.isEmpty()) {
showToast(R.string.place_autocomplete_reverse_geocoding_error_message)
} else {
openPlaceCard(suggestions.first())
}
}.onError { error ->
Log.d(LOG_TAG, "Reverse geocoding error", error)
showToast(R.string.place_autocomplete_reverse_geocoding_error_message)
}
}
}
}
override fun onNavigationStatusChanged(navigationStatus: NavigationStatus) {
when (navigationStatus) {
NavigationStatus.IDLE -> {
ui.queryEditText.visibility = View.VISIBLE
}
NavigationStatus.STARTED -> {
mapControl.navigationCamera.requestNavigationCameraToOverview()
closePlaceCard()
ui.queryEditText.visibility = View.GONE
}
else -> {}
}
}
private fun showToast(@StringRes resId: Int): Unit =
Toast.makeText(mapControl.context, resId, Toast.LENGTH_LONG).show()
private fun View.hideKeyboard() =
(mapControl.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager)
.hideSoftInputFromWindow(windowToken, 0)
private class MapMarkersManager(mapView: MapView) {
private val mapboxMap = mapView.mapboxMap
private val circleAnnotationManager = mapView.annotations.createCircleAnnotationManager(null)
private val markers = mutableMapOf<String, Point>()
fun clearMarkers() {
markers.clear()
circleAnnotationManager.deleteAll()
}
fun showMarker(coordinate: Point) {
clearMarkers()
val circleAnnotationOptions: CircleAnnotationOptions = CircleAnnotationOptions()
.withPoint(coordinate)
.withCircleRadius(8.0)
.withCircleColor("#ee4e8b")
.withCircleStrokeWidth(2.0)
.withCircleStrokeColor("#ffffff")
val annotation = circleAnnotationManager.create(circleAnnotationOptions)
markers[annotation.id] = coordinate
CameraOptions.Builder()
.center(coordinate)
.padding(MARKERS_INSETS_OPEN_CARD)
.zoom(14.0)
.build().also {
mapboxMap.setCamera(it)
}
}
}
private companion object {
const val LOG_TAG = "AutocompleteUiActivity"
val MARKERS_EDGE_OFFSET = dpToPx(64).toDouble()
val PLACE_CARD_HEIGHT = dpToPx(300).toDouble()
val MARKERS_TOP_OFFSET = dpToPx(88).toDouble()
val MARKERS_INSETS_OPEN_CARD = EdgeInsets(
MARKERS_TOP_OFFSET, MARKERS_EDGE_OFFSET, PLACE_CARD_HEIGHT, MARKERS_EDGE_OFFSET
)
val REGION_LEVEL_TYPES = listOf(
PlaceAutocompleteType.AdministrativeUnit.Country,
PlaceAutocompleteType.AdministrativeUnit.Region
)
val DISTRICT_LEVEL_TYPES = REGION_LEVEL_TYPES + listOf(
PlaceAutocompleteType.AdministrativeUnit.Postcode,
PlaceAutocompleteType.AdministrativeUnit.District
)
val LOCALITY_LEVEL_TYPES = DISTRICT_LEVEL_TYPES + listOf(
PlaceAutocompleteType.AdministrativeUnit.Place,
PlaceAutocompleteType.AdministrativeUnit.Locality
)
private val ALL_TYPES = listOf(
PlaceAutocompleteType.Poi,
PlaceAutocompleteType.AdministrativeUnit.Country,
PlaceAutocompleteType.AdministrativeUnit.Region,
PlaceAutocompleteType.AdministrativeUnit.Postcode,
PlaceAutocompleteType.AdministrativeUnit.District,
PlaceAutocompleteType.AdministrativeUnit.Place,
PlaceAutocompleteType.AdministrativeUnit.Locality,
PlaceAutocompleteType.AdministrativeUnit.Neighborhood,
PlaceAutocompleteType.AdministrativeUnit.Street,
PlaceAutocompleteType.AdministrativeUnit.Address,
)
private fun dpToPx(dp: Int): Int = (dp * Resources.getSystem().displayMetrics.density).toInt()
}
}

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="8dp" />
<solid android:color="@color/white" />
</shape>

View file

@ -4,6 +4,23 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<EditText
android:id="@+id/query"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:autofillHints="@null"
android:background="@drawable/card_background"
android:textColor="@color/black"
android:elevation="4dp"
android:hint="@string/place_autocomplete_query_hint"
android:inputType="text"
android:minHeight="?actionBarSize"
android:paddingHorizontal="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.mapbox.maps.MapView
android:id="@+id/mapView"
android:layout_width="0dp"
@ -38,6 +55,39 @@
app:srcCompat="@android:drawable/ic_delete" />
</androidx.cardview.widget.CardView>
<com.mapbox.search.ui.view.SearchResultsView
android:id="@+id/searchResults"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="16dp"
android:background="@drawable/card_background"
android:clipToPadding="false"
android:elevation="4dp"
android:paddingTop="8dp"
android:paddingBottom="22dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/query"
/>
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/searchContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:elevation="@dimen/search_card_elevation"
>
<com.mapbox.search.ui.view.place.SearchPlaceBottomSheetView
android:id="@+id/searchPlaces"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:elevation="@dimen/search_card_elevation"
/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<com.mapbox.navigation.ui.components.maneuver.view.MapboxManeuverView
android:id="@+id/maneuverView"
android:layout_width="0dp"

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="search_card_elevation">8dp</dimen>
</resources>

View file

@ -1,4 +1,10 @@
<resources>
<string name="app_name">Garmin</string>
<string name="mapbox_access_token">pk.eyJ1IjoibWFwcy16dHNoIiwiYSI6ImNsbDl4YXU4cjA3eW8zcXMzbXdjYjNsN3oifQ.kbDCjthamXvXX_pAdsq3hQ</string>
<string name="place_autocomplete_query_hint">Search for places</string>
<string name="place_autocomplete_reverse_geocoding_error_message">Unable to locate selected coordinate</string>
<string name="place_autocomplete_selection_error">Error happened during request</string>
<string name="not_implemented_yet">Not implemented yet</string>
</resources>