diff --git a/app/build.gradle b/app/build.gradle index 8eb3c08..3a9e254 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' 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..f3f4082 100644 --- a/app/src/main/java/eu/ztsh/garmin/mapbox/MapControl.kt +++ b/app/src/main/java/eu/ztsh/garmin/mapbox/MapControl.kt @@ -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) } diff --git a/app/src/main/java/eu/ztsh/garmin/mapbox/NavigationStatus.kt b/app/src/main/java/eu/ztsh/garmin/mapbox/NavigationStatus.kt new file mode 100644 index 0000000..468a697 --- /dev/null +++ b/app/src/main/java/eu/ztsh/garmin/mapbox/NavigationStatus.kt @@ -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() + 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) + +} \ 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..1538318 100644 --- a/app/src/main/java/eu/ztsh/garmin/mapbox/RouteControl.kt +++ b/app/src/main/java/eu/ztsh/garmin/mapbox/RouteControl.kt @@ -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() { 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..a8f8fd9 --- /dev/null +++ b/app/src/main/java/eu/ztsh/garmin/mapbox/SearchControl.kt @@ -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 = "" + 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 -> + 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 = 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() + + 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() + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/card_background.xml b/app/src/main/res/drawable/card_background.xml new file mode 100644 index 0000000..e32ecf5 --- /dev/null +++ b/app/src/main/res/drawable/card_background.xml @@ -0,0 +1,8 @@ + + + + + + + \ 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 cb6c22d..bfea266 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -4,6 +4,23 @@ android:layout_width="match_parent" android:layout_height="match_parent"> + + + + + + + + + + + 8dp + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1c4d613..774ae0a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,10 @@ Garmin pk.eyJ1IjoibWFwcy16dHNoIiwiYSI6ImNsbDl4YXU4cjA3eW8zcXMzbXdjYjNsN3oifQ.kbDCjthamXvXX_pAdsq3hQ + + Search for places + Unable to locate selected coordinate + Error happened during request + Not implemented yet +