From 65cdeb17d0248b18846160a00083f7d3d7aa8032 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Wed, 7 Aug 2024 19:57:38 +0200 Subject: [PATCH] feat: Search basics --- app/build.gradle | 8 +- .../main/java/eu/ztsh/garmin/MainActivity.kt | 1 + app/src/main/java/eu/ztsh/garmin/UI.kt | 3 + .../java/eu/ztsh/garmin/mapbox/MapControl.kt | 5 + .../garmin/mapbox/NavigationStateListener.kt | 12 + .../eu/ztsh/garmin/mapbox/RouteControl.kt | 8 +- .../eu/ztsh/garmin/mapbox/SearchControl.kt | 270 ++++++++++++++++++ app/src/main/res/layout/activity_main.xml | 1 + app/src/main/res/values/strings.xml | 1 + 9 files changed, 304 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/eu/ztsh/garmin/mapbox/NavigationStateListener.kt create mode 100644 app/src/main/java/eu/ztsh/garmin/mapbox/SearchControl.kt diff --git a/app/build.gradle b/app/build.gradle index e08ff12..3a9e254 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -40,20 +40,26 @@ android { } ext { mapboxVersion = '3.2.0' - searchApiVersion = '2.3.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' diff --git a/app/src/main/java/eu/ztsh/garmin/MainActivity.kt b/app/src/main/java/eu/ztsh/garmin/MainActivity.kt index bb84358..9c4e337 100644 --- a/app/src/main/java/eu/ztsh/garmin/MainActivity.kt +++ b/app/src/main/java/eu/ztsh/garmin/MainActivity.kt @@ -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() { diff --git a/app/src/main/java/eu/ztsh/garmin/UI.kt b/app/src/main/java/eu/ztsh/garmin/UI.kt index c27faff..c16bae5 100644 --- a/app/src/main/java/eu/ztsh/garmin/UI.kt +++ b/app/src/main/java/eu/ztsh/garmin/UI.kt @@ -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 } \ No newline at end of file diff --git a/app/src/main/java/eu/ztsh/garmin/mapbox/MapControl.kt b/app/src/main/java/eu/ztsh/garmin/mapbox/MapControl.kt index 5ae5688..5c5429b 100644 --- a/app/src/main/java/eu/ztsh/garmin/mapbox/MapControl.kt +++ b/app/src/main/java/eu/ztsh/garmin/mapbox/MapControl.kt @@ -58,6 +58,8 @@ class MapControl( private lateinit var locationObserver: LocationObserver private lateinit var routeProgressObserver: RouteProgressObserver private lateinit var voiceInstructionsObserver: VoiceInstructionsObserver + private val searchControl = SearchControl(this, ui) + private val navigationStateListener = NavigationStateListener() fun init() { viewportDataSource = MapboxNavigationViewportDataSource(ui.mapView.mapboxMap) @@ -133,6 +135,7 @@ class MapControl( mapboxNavigation.registerLocationObserver(locationObserver) mapboxNavigation.registerRouteProgressObserver(routeProgressObserver) mapboxNavigation.registerVoiceInstructionsObserver(voiceInstructionsObserver) + mapboxNavigation.registerNavigationSessionStateObserver(navigationStateListener) replay.onAttached(mapboxNavigation) } @@ -142,6 +145,8 @@ class MapControl( mapboxNavigation.unregisterLocationObserver(locationObserver) mapboxNavigation.unregisterRouteProgressObserver(routeProgressObserver) mapboxNavigation.unregisterVoiceInstructionsObserver(voiceInstructionsObserver) + mapboxNavigation.unregisterNavigationSessionStateObserver(navigationStateListener) + replay.onDetached(mapboxNavigation) } diff --git a/app/src/main/java/eu/ztsh/garmin/mapbox/NavigationStateListener.kt b/app/src/main/java/eu/ztsh/garmin/mapbox/NavigationStateListener.kt new file mode 100644 index 0000000..e4c5b67 --- /dev/null +++ b/app/src/main/java/eu/ztsh/garmin/mapbox/NavigationStateListener.kt @@ -0,0 +1,12 @@ +package eu.ztsh.garmin.mapbox + +import com.mapbox.navigation.core.trip.session.NavigationSessionState +import com.mapbox.navigation.core.trip.session.NavigationSessionStateObserver + +class NavigationStateListener: NavigationSessionStateObserver { + + override fun onNavigationSessionStateChanged(navigationSession: NavigationSessionState) { +// TODO("Not yet implemented") + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/ztsh/garmin/mapbox/RouteControl.kt b/app/src/main/java/eu/ztsh/garmin/mapbox/RouteControl.kt index a59a899..ecdb76b 100644 --- a/app/src/main/java/eu/ztsh/garmin/mapbox/RouteControl.kt +++ b/app/src/main/java/eu/ztsh/garmin/mapbox/RouteControl.kt @@ -116,10 +116,10 @@ class RouteControl(private val mapControl: MapControl, ui: UI, private val conte routeLineView.initializeLayers(it) // add long click listener that search for a route to the clicked destination - ui.mapView.gestures.addOnMapLongClickListener { point -> - findRoute(point) - true - } +// ui.mapView.gestures.addOnMapLongClickListener { point -> +// findRoute(point) +// true +// } } // initialize view interactions diff --git a/app/src/main/java/eu/ztsh/garmin/mapbox/SearchControl.kt b/app/src/main/java/eu/ztsh/garmin/mapbox/SearchControl.kt new file mode 100644 index 0000000..94a2b74 --- /dev/null +++ b/app/src/main/java/eu/ztsh/garmin/mapbox/SearchControl.kt @@ -0,0 +1,270 @@ +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) { + + // Set your Access Token here if it's not already set in some other way + // MapboxOptions.accessToken = "" + 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) { + // 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 -> + showToast(R.string.not_implemented_yet) +// startActivity(geoIntent(searchPlace.coordinate)) + } + + addOnShareClickListener { searchPlace -> + showToast(R.string.not_implemented_yet) +// startActivity(shareIntent(searchPlace)) + } + } + + 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 = 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) + } + } + } + } + + 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() + + 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 PERMISSIONS_REQUEST_LOCATION = 0 + + 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() + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index fe89ff7..bfea266 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -11,6 +11,7 @@ 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" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8a23bc0..774ae0a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,5 +5,6 @@ Search for places Unable to locate selected coordinate Error happened during request + Not implemented yet