diff --git a/app/build.gradle b/app/build.gradle index f39fbd03..bcf28ae5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -157,6 +157,7 @@ dependencies { googleImplementation 'com.google.code.gson:gson:2.8.6' googleImplementation 'com.google.android.datatransport:transport-runtime:2.2.5' googleImplementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' + googleImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.4.1' // Mapbox places (autocomplete) // forked this library and included through JitPack to fix https://github.com/mapbox/mapbox-plugins-android/issues/1011 diff --git a/app/src/foss/java/net/vonforst/evmap/autocomplete/Autocomplete.kt b/app/src/foss/java/net/vonforst/evmap/autocomplete/Autocomplete.kt new file mode 100644 index 00000000..878a594d --- /dev/null +++ b/app/src/foss/java/net/vonforst/evmap/autocomplete/Autocomplete.kt @@ -0,0 +1,5 @@ +package net.vonforst.evmap.autocomplete + +import android.content.Context + +fun getAutocompleteProviders(context: Context) = listOf(MapboxAutocompleteProvider(context)) \ No newline at end of file diff --git a/app/src/foss/res/values-de/values.xml b/app/src/foss/res/values-de/values.xml index 29f8d793..112a5827 100644 --- a/app/src/foss/res/values-de/values.xml +++ b/app/src/foss/res/values-de/values.xml @@ -3,6 +3,12 @@ OpenStreetMap (Mapbox) + + OpenStreetMap (Mapbox) + + + mapbox + Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.\n\nGoogle zieht von der Spende 30% Gebühren ab. Mit PayPal spenden \ No newline at end of file diff --git a/app/src/foss/res/values/values.xml b/app/src/foss/res/values/values.xml index eb107ce7..3fcbe8f9 100644 --- a/app/src/foss/res/values/values.xml +++ b/app/src/foss/res/values/values.xml @@ -6,6 +6,13 @@ mapbox + + OpenStreetMap (Mapbox) + + + mapbox + + mapbox mapbox Do you find EVMap useful? Support its development by sending a donation to the developer. Donate with PayPal diff --git a/app/src/google/java/net/vonforst/evmap/autocomplete/Autocomplete.kt b/app/src/google/java/net/vonforst/evmap/autocomplete/Autocomplete.kt new file mode 100644 index 00000000..ed0ef8fd --- /dev/null +++ b/app/src/google/java/net/vonforst/evmap/autocomplete/Autocomplete.kt @@ -0,0 +1,11 @@ +package net.vonforst.evmap.autocomplete + +import android.content.Context +import net.vonforst.evmap.storage.PreferenceDataSource + +fun getAutocompleteProviders(context: Context) = + if (PreferenceDataSource(context).searchProvider == "google") { + listOf(GooglePlacesAutocompleteProvider(context), MapboxAutocompleteProvider(context)) + } else { + listOf(MapboxAutocompleteProvider(context), GooglePlacesAutocompleteProvider(context)) + } \ No newline at end of file diff --git a/app/src/google/java/net/vonforst/evmap/autocomplete/GooglePlacesAutocompleteProvider.kt b/app/src/google/java/net/vonforst/evmap/autocomplete/GooglePlacesAutocompleteProvider.kt new file mode 100644 index 00000000..07ec70e6 --- /dev/null +++ b/app/src/google/java/net/vonforst/evmap/autocomplete/GooglePlacesAutocompleteProvider.kt @@ -0,0 +1,110 @@ +package net.vonforst.evmap.autocomplete + +import android.content.Context +import android.graphics.Typeface +import android.text.style.CharacterStyle +import android.text.style.StyleSpan +import com.car2go.maps.google.adapter.AnyMapAdapter +import com.car2go.maps.util.SphericalUtil +import com.google.android.gms.common.api.ApiException +import com.google.android.gms.tasks.Tasks.await +import com.google.android.libraries.maps.model.LatLng +import com.google.android.libraries.maps.model.LatLngBounds +import com.google.android.libraries.places.api.Places +import com.google.android.libraries.places.api.model.AutocompleteSessionToken +import com.google.android.libraries.places.api.model.Place +import com.google.android.libraries.places.api.model.RectangularBounds +import com.google.android.libraries.places.api.net.FetchPlaceRequest +import com.google.android.libraries.places.api.net.FindAutocompletePredictionsRequest +import com.google.android.libraries.places.api.net.PlacesStatusCodes +import kotlinx.coroutines.tasks.await +import net.vonforst.evmap.R +import java.util.concurrent.ExecutionException +import kotlin.math.sqrt + + +class GooglePlacesAutocompleteProvider(val context: Context) : AutocompleteProvider { + private var token = AutocompleteSessionToken.newInstance() + private val client = Places.createClient(context) + private val bold: CharacterStyle = StyleSpan(Typeface.BOLD) + + override fun autocomplete( + query: String, + location: com.car2go.maps.model.LatLng? + ): List { + val request = FindAutocompletePredictionsRequest.builder().apply { + if (location != null) { + setLocationBias(calcLocationBias(location)) + setOrigin(LatLng(location.latitude, location.longitude)) + } + setSessionToken(token) + setQuery(query) + }.build() + try { + val result = + await(client.findAutocompletePredictions(request)).autocompletePredictions + return result.map { + AutocompletePlace( + it.getPrimaryText(bold), + it.getSecondaryText(bold), + it.placeId, + it.distanceMeters, + it.placeTypes.map { AutocompletePlaceType.valueOf(it.name) }) + } + } catch (e: ExecutionException) { + val cause = e.cause + if (cause is ApiException) { + if (cause.statusCode == PlacesStatusCodes.OVER_QUERY_LIMIT) { + throw ApiUnavailableException() + } + } + throw e + } + } + + override suspend fun getDetails(id: String): PlaceWithBounds { + val request = + FetchPlaceRequest.builder(id, listOf(Place.Field.LAT_LNG, Place.Field.VIEWPORT)).build() + try { + val place = client.fetchPlace(request).await().place + token = AutocompleteSessionToken.newInstance() + return PlaceWithBounds( + AnyMapAdapter.adapt(place.latLng), + AnyMapAdapter.adapt(place.viewport) + ) + } catch (e: ApiException) { + if (e.statusCode == PlacesStatusCodes.OVER_QUERY_LIMIT) { + throw ApiUnavailableException() + } else { + throw e + } + } + } + + override fun getAttributionString(): Int = R.string.places_powered_by_google + + override fun getAttributionImage(dark: Boolean): Int = + if (dark) R.drawable.places_powered_by_google_dark else R.drawable.places_powered_by_google_light + + private fun calcLocationBias(location: com.car2go.maps.model.LatLng): RectangularBounds { + val radius = 100e3 // meters + val northEast = + SphericalUtil.computeOffset( + location, + radius * sqrt(2.0), + 45.0 + ) + val southWest = + SphericalUtil.computeOffset( + location, + radius * sqrt(2.0), + 225.0 + ) + return RectangularBounds.newInstance( + LatLngBounds( + AnyMapAdapter.adapt(southWest), + AnyMapAdapter.adapt(northEast) + ) + ) + } +} \ No newline at end of file diff --git a/app/src/google/res/values-de/values.xml b/app/src/google/res/values-de/values.xml index 80fcd250..8c857f77 100644 --- a/app/src/google/res/values-de/values.xml +++ b/app/src/google/res/values-de/values.xml @@ -4,6 +4,10 @@ Google Maps OpenStreetMap (Mapbox) + + Google Maps + OpenStreetMap (Mapbox) + Findest du EVMap nützlich? Unterstütze die Weiterentwicklung der App mit einer Spende an den Entwickler.\n\nGoogle zieht von der Spende 15% Gebühren ab. EVMap läuft unter Android Auto und nutzt dafür deinen Standort. Keine Ladestationen in der Nähe gefunden diff --git a/app/src/google/res/values/values.xml b/app/src/google/res/values/values.xml index 9daabda0..f7114f10 100644 --- a/app/src/google/res/values/values.xml +++ b/app/src/google/res/values/values.xml @@ -8,7 +8,16 @@ google mapbox + + Google Maps + OpenStreetMap (Mapbox) + + + google + mapbox + google + mapbox Do you find EVMap useful? Support its development by sending a donation to the developer.\n\nGoogle takes 15% off every donation. EVMap is running on Android Auto and using your location. No nearby chargers found diff --git a/app/src/main/java/android/widget/Filter.java b/app/src/main/java/android/widget/Filter.java new file mode 100644 index 00000000..07fa3986 --- /dev/null +++ b/app/src/main/java/android/widget/Filter.java @@ -0,0 +1,321 @@ +/* + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.widget; + +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Message; +import android.util.Log; + +/** + * Copy of android.widget.Filter, exposing the hidden setDelayer() method. + * + *

A filter constrains data with a filtering pattern.

+ * + *

Filters are usually created by {@link android.widget.Filterable} + * classes.

+ * + *

Filtering operations performed by calling {@link #filter(CharSequence)} or + * {@link #filter(CharSequence, android.widget.Filter.FilterListener)} are + * performed asynchronously. When these methods are called, a filtering request + * is posted in a request queue and processed later. Any call to one of these + * methods will cancel any previous non-executed filtering request.

+ * + * @see android.widget.Filterable + */ +public abstract class Filter { + private static final String LOG_TAG = "Filter"; + + private static final String THREAD_NAME = "Filter"; + private static final int FILTER_TOKEN = 0xD0D0F00D; + private static final int FINISH_TOKEN = 0xDEADBEEF; + + private Handler mThreadHandler; + private Handler mResultHandler; + + private Delayer mDelayer; + + private final Object mLock = new Object(); + + /** + *

Creates a new asynchronous filter.

+ */ + public Filter() { + mResultHandler = new ResultsHandler(); + } + + /** + * Provide an interface that decides how long to delay the message for a given query. Useful + * for heuristics such as posting a delay for the delete key to avoid doing any work while the + * user holds down the delete key. + * + * @param delayer The delayer. + * @hide + */ + public void setDelayer(Delayer delayer) { + synchronized (mLock) { + mDelayer = delayer; + } + } + + /** + *

Starts an asynchronous filtering operation. Calling this method + * cancels all previous non-executed filtering requests and posts a new + * filtering request that will be executed later.

+ * + * @param constraint the constraint used to filter the data + * @see #filter(CharSequence, android.widget.Filter.FilterListener) + */ + public final void filter(CharSequence constraint) { + filter(constraint, null); + } + + /** + *

Starts an asynchronous filtering operation. Calling this method + * cancels all previous non-executed filtering requests and posts a new + * filtering request that will be executed later.

+ * + *

Upon completion, the listener is notified.

+ * + * @param constraint the constraint used to filter the data + * @param listener a listener notified upon completion of the operation + * @see #filter(CharSequence) + * @see #performFiltering(CharSequence) + * @see #publishResults(CharSequence, android.widget.Filter.FilterResults) + */ + public final void filter(CharSequence constraint, FilterListener listener) { + synchronized (mLock) { + if (mThreadHandler == null) { + HandlerThread thread = new HandlerThread( + THREAD_NAME, android.os.Process.THREAD_PRIORITY_BACKGROUND); + thread.start(); + mThreadHandler = new RequestHandler(thread.getLooper()); + } + + final long delay = (mDelayer == null) ? 0 : mDelayer.getPostingDelay(constraint); + + Message message = mThreadHandler.obtainMessage(FILTER_TOKEN); + + RequestArguments args = new RequestArguments(); + // make sure we use an immutable copy of the constraint, so that + // it doesn't change while the filter operation is in progress + args.constraint = constraint != null ? constraint.toString() : null; + args.listener = listener; + message.obj = args; + + mThreadHandler.removeMessages(FILTER_TOKEN); + mThreadHandler.removeMessages(FINISH_TOKEN); + mThreadHandler.sendMessageDelayed(message, delay); + } + } + + /** + *

Invoked in a worker thread to filter the data according to the + * constraint. Subclasses must implement this method to perform the + * filtering operation. Results computed by the filtering operation + * must be returned as a {@link android.widget.Filter.FilterResults} that + * will then be published in the UI thread through + * {@link #publishResults(CharSequence, + * android.widget.Filter.FilterResults)}.

+ * + *

Contract: When the constraint is null, the original + * data must be restored.

+ * + * @param constraint the constraint used to filter the data + * @return the results of the filtering operation + * @see #filter(CharSequence, android.widget.Filter.FilterListener) + * @see #publishResults(CharSequence, android.widget.Filter.FilterResults) + * @see android.widget.Filter.FilterResults + */ + protected abstract FilterResults performFiltering(CharSequence constraint); + + /** + *

Invoked in the UI thread to publish the filtering results in the + * user interface. Subclasses must implement this method to display the + * results computed in {@link #performFiltering}.

+ * + * @param constraint the constraint used to filter the data + * @param results the results of the filtering operation + * @see #filter(CharSequence, android.widget.Filter.FilterListener) + * @see #performFiltering(CharSequence) + * @see android.widget.Filter.FilterResults + */ + protected abstract void publishResults(CharSequence constraint, + FilterResults results); + + /** + *

Converts a value from the filtered set into a CharSequence. Subclasses + * should override this method to convert their results. The default + * implementation returns an empty String for null values or the default + * String representation of the value.

+ * + * @param resultValue the value to convert to a CharSequence + * @return a CharSequence representing the value + */ + public CharSequence convertResultToString(Object resultValue) { + return resultValue == null ? "" : resultValue.toString(); + } + + /** + *

Holds the results of a filtering operation. The results are the values + * computed by the filtering operation and the number of these values.

+ */ + protected static class FilterResults { + public FilterResults() { + // nothing to see here + } + + /** + *

Contains all the values computed by the filtering operation.

+ */ + public Object values; + + /** + *

Contains the number of values computed by the filtering + * operation.

+ */ + public int count; + } + + /** + *

Listener used to receive a notification upon completion of a filtering + * operation.

+ */ + public static interface FilterListener { + /** + *

Notifies the end of a filtering operation.

+ * + * @param count the number of values computed by the filter + */ + public void onFilterComplete(int count); + } + + /** + *

Worker thread handler. When a new filtering request is posted from + * {@link android.widget.Filter#filter(CharSequence, android.widget.Filter.FilterListener)}, + * it is sent to this handler.

+ */ + private class RequestHandler extends Handler { + public RequestHandler(Looper looper) { + super(looper); + } + + /** + *

Handles filtering requests by calling + * {@link Filter#performFiltering} and then sending a message + * with the results to the results handler.

+ * + * @param msg the filtering request + */ + public void handleMessage(Message msg) { + int what = msg.what; + Message message; + switch (what) { + case FILTER_TOKEN: + RequestArguments args = (RequestArguments) msg.obj; + try { + args.results = performFiltering(args.constraint); + } catch (Exception e) { + args.results = new FilterResults(); + Log.w(LOG_TAG, "An exception occured during performFiltering()!", e); + } finally { + message = mResultHandler.obtainMessage(what); + message.obj = args; + message.sendToTarget(); + } + + synchronized (mLock) { + if (mThreadHandler != null) { + Message finishMessage = mThreadHandler.obtainMessage(FINISH_TOKEN); + mThreadHandler.sendMessageDelayed(finishMessage, 3000); + } + } + break; + case FINISH_TOKEN: + synchronized (mLock) { + if (mThreadHandler != null) { + mThreadHandler.getLooper().quit(); + mThreadHandler = null; + } + } + break; + } + } + } + + /** + *

Handles the results of a filtering operation. The results are + * handled in the UI thread.

+ */ + private class ResultsHandler extends Handler { + /** + *

Messages received from the request handler are processed in the + * UI thread. The processing involves calling + * {@link Filter#publishResults(CharSequence, + * android.widget.Filter.FilterResults)} + * to post the results back in the UI and then notifying the listener, + * if any.

+ * + * @param msg the filtering results + */ + @Override + public void handleMessage(Message msg) { + RequestArguments args = (RequestArguments) msg.obj; + + publishResults(args.constraint, args.results); + if (args.listener != null) { + int count = args.results != null ? args.results.count : -1; + args.listener.onFilterComplete(count); + } + } + } + + /** + *

Holds the arguments of a filtering request as well as the results + * of the request.

+ */ + private static class RequestArguments { + /** + *

The constraint used to filter the data.

+ */ + CharSequence constraint; + + /** + *

The listener to notify upon completion. Can be null.

+ */ + FilterListener listener; + + /** + *

The results of the filtering operation.

+ */ + FilterResults results; + } + + /** + * @hide + */ + public interface Delayer { + + /** + * @param constraint The constraint passed to {@link Filter#filter(CharSequence)} + * @return The delay that should be used for + * {@link Handler#sendMessageDelayed(android.os.Message, long)} + */ + long getPostingDelay(CharSequence constraint); + } +} diff --git a/app/src/main/java/net/vonforst/evmap/Utils.kt b/app/src/main/java/net/vonforst/evmap/Utils.kt index 397e0d0e..db236f78 100644 --- a/app/src/main/java/net/vonforst/evmap/Utils.kt +++ b/app/src/main/java/net/vonforst/evmap/Utils.kt @@ -1,5 +1,7 @@ package net.vonforst.evmap +import android.content.Context +import android.content.res.Configuration import android.graphics.Typeface import android.os.Bundle import android.text.* @@ -9,6 +11,7 @@ import androidx.lifecycle.Observer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext +import java.util.* fun Bundle.optDouble(name: String): Double? { if (!this.containsKey(name)) return null @@ -80,6 +83,8 @@ fun max(a: Int?, b: Int?): Int? { } } +fun List.containsAny(vararg values: T) = values.any { this.contains(it) } + public suspend fun LiveData.await(): T { return withContext(Dispatchers.Main.immediate) { suspendCancellableCoroutine { continuation -> @@ -97,4 +102,14 @@ public suspend fun LiveData.await(): T { } } } +} + +fun Context.isDarkMode() = + (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + +const val kmPerMile = 1.609344 +const val meterPerFt = 0.3048 + +fun shouldUseImperialUnits(): Boolean { + return Locale.getDefault().country in listOf("US", "GB", "MM", "LR") } \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/adapter/PlaceAutocompleteAdapter.kt b/app/src/main/java/net/vonforst/evmap/adapter/PlaceAutocompleteAdapter.kt new file mode 100644 index 00000000..72aa205e --- /dev/null +++ b/app/src/main/java/net/vonforst/evmap/adapter/PlaceAutocompleteAdapter.kt @@ -0,0 +1,156 @@ +package net.vonforst.evmap.adapter + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.LiveData +import com.car2go.maps.model.LatLng +import net.vonforst.evmap.R +import net.vonforst.evmap.autocomplete.* +import net.vonforst.evmap.containsAny +import net.vonforst.evmap.databinding.ItemAutocompleteResultBinding +import net.vonforst.evmap.isDarkMode +import net.vonforst.evmap.storage.PreferenceDataSource + +class PlaceAutocompleteAdapter(val context: Context, val location: LiveData) : + BaseAdapter(), Filterable { + private var resultList: List? = null + private val providers = getAutocompleteProviders(context) + private val typeItem = 0 + private val typeAttribution = 1 + var currentProvider: AutocompleteProvider? = null + + data class ViewHolder(val binding: ItemAutocompleteResultBinding) + + override fun getCount(): Int { + return resultList?.let { it.size + 1 } ?: 0 + } + + override fun getItem(position: Int): AutocompletePlace? { + return if (position < resultList!!.size) resultList!![position] else null + } + + override fun getItemViewType(position: Int): Int { + return if (position < resultList!!.size) typeItem else typeAttribution + } + + override fun getViewTypeCount(): Int = 2 + + override fun getItemId(position: Int): Long { + return 0 + } + + override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View { + var view = convertView + if (getItemViewType(position) == typeItem) { + val viewHolder: ViewHolder + if (view == null) { + val binding: ItemAutocompleteResultBinding = DataBindingUtil.inflate( + LayoutInflater.from(context), + R.layout.item_autocomplete_result, + parent, + false + ) + view = binding.root + viewHolder = ViewHolder(binding) + view.tag = viewHolder + } else { + viewHolder = view.tag as ViewHolder + } + val place = resultList!![position] + bindView(viewHolder, place) + } else if (getItemViewType(position) == typeAttribution) { + if (view == null) { + view = LayoutInflater.from(context) + .inflate(R.layout.item_autocomplete_attribution, parent, false) + } + (view as ImageView).apply { + setImageResource(currentProvider?.getAttributionImage(context.isDarkMode()) ?: 0) + contentDescription = context.getString(currentProvider?.getAttributionString() ?: 0) + } + + } + return view!! + } + + private fun bindView( + viewHolder: ViewHolder, + place: AutocompletePlace + ) { + viewHolder.binding.item = place + } + + override fun getFilter(): Filter { + return object : Filter() { + var delaySet = false + + init { + if (PreferenceDataSource(context).searchProvider == "mapbox") { + // set delay to 500 ms to reduce paid Mapbox API requests + this.setDelayer { 500L } + } + } + + override fun publishResults(constraint: CharSequence?, results: FilterResults?) { + if (results != null && results.count > 0) { + notifyDataSetChanged() + } else { + notifyDataSetInvalidated() + } + } + + override fun performFiltering(constraint: CharSequence?): FilterResults { + val filterResults = FilterResults() + if (constraint != null) { + for (provider in providers) { + try { + resultList = + provider.autocomplete(constraint.toString(), location.value) + currentProvider = provider + break + } catch (e: ApiUnavailableException) { + e.printStackTrace() + } + } + filterResults.values = resultList + filterResults.count = resultList!!.size + } + + + if (currentProvider is MapboxAutocompleteProvider && !delaySet) { + // set delay to 500 ms to reduce paid Mapbox API requests + this.setDelayer { 500L } + } + + return filterResults + } + } + } + +} + + +fun iconForPlaceType(types: List): Int = + when { + types.containsAny( + AutocompletePlaceType.LIGHT_RAIL_STATION, + AutocompletePlaceType.BUS_STATION, + AutocompletePlaceType.TRAIN_STATION, + AutocompletePlaceType.TRANSIT_STATION + ) -> { + R.drawable.ic_place_type_train + } + types.contains(AutocompletePlaceType.AIRPORT) -> { + R.drawable.ic_place_type_airport + } + // TODO: extend this with icons for more place categories + else -> { + R.drawable.ic_place_type_default + } + } + +fun isSpecialPlace(types: List): Boolean = + iconForPlaceType(types) != R.drawable.ic_place_type_default \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/autocomplete/Autocomplete.kt b/app/src/main/java/net/vonforst/evmap/autocomplete/Autocomplete.kt deleted file mode 100644 index 4b687871..00000000 --- a/app/src/main/java/net/vonforst/evmap/autocomplete/Autocomplete.kt +++ /dev/null @@ -1,54 +0,0 @@ -package net.vonforst.evmap.autocomplete - -import android.content.Context -import android.content.Intent -import android.view.inputmethod.InputMethodManager -import androidx.core.os.ConfigurationCompat -import androidx.fragment.app.Fragment -import com.car2go.maps.model.LatLng -import com.car2go.maps.model.LatLngBounds -import com.mapbox.geojson.BoundingBox -import com.mapbox.geojson.Point -import com.mapbox.mapboxsdk.plugins.places.autocomplete.PlaceAutocomplete -import com.mapbox.mapboxsdk.plugins.places.autocomplete.model.PlaceOptions -import net.vonforst.evmap.R -import net.vonforst.evmap.fragment.REQUEST_AUTOCOMPLETE -import net.vonforst.evmap.viewmodel.PlaceWithBounds - - -fun launchAutocomplete(fragment: Fragment, location: LatLng?) { - val placeOptions = PlaceOptions.builder().apply { - location?.let { - proximity(Point.fromLngLat(location.longitude, location.latitude)) - } - language(ConfigurationCompat.getLocales(fragment.resources.configuration)[0].language) - }.build(PlaceOptions.MODE_CARDS) - - val intent = PlaceAutocomplete.IntentBuilder() - .accessToken(fragment.getString(R.string.mapbox_key)) - .placeOptions(placeOptions) - .build(fragment.requireActivity()) - .addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) - fragment.startActivityForResult(intent, REQUEST_AUTOCOMPLETE) - - // show keyboard - val imm = fragment.requireContext() - .getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.toggleSoftInput(0, 0) -} - -fun handleAutocompleteResult(intent: Intent): PlaceWithBounds? { - val place = PlaceAutocomplete.getPlace(intent) ?: return null - val bbox = place.bbox()?.toLatLngBounds() - val center = place.center()!!.toLatLng() - return PlaceWithBounds(center, bbox) -} - -private fun BoundingBox.toLatLngBounds(): LatLngBounds { - return LatLngBounds( - southwest().toLatLng(), - northeast().toLatLng() - ) -} - -private fun Point.toLatLng(): LatLng = LatLng(this.latitude(), this.longitude()) \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/autocomplete/AutocompleteProvider.kt b/app/src/main/java/net/vonforst/evmap/autocomplete/AutocompleteProvider.kt new file mode 100644 index 00000000..e695ad9d --- /dev/null +++ b/app/src/main/java/net/vonforst/evmap/autocomplete/AutocompleteProvider.kt @@ -0,0 +1,183 @@ +package net.vonforst.evmap.autocomplete + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import com.car2go.maps.model.LatLng +import com.car2go.maps.model.LatLngBounds + +interface AutocompleteProvider { + fun autocomplete(query: String, location: LatLng?): List + suspend fun getDetails(id: String): PlaceWithBounds + + @StringRes + fun getAttributionString(): Int + + @DrawableRes + fun getAttributionImage(dark: Boolean): Int +} + +data class AutocompletePlace( + val primaryText: CharSequence, + val secondaryText: CharSequence, + val id: String, + val distanceMeters: Int?, + val types: List +) + +class ApiUnavailableException : Exception() + +enum class AutocompletePlaceType { + // based on Google Places Place.Type enum + OTHER, + ACCOUNTING, + ADMINISTRATIVE_AREA_LEVEL_1, + ADMINISTRATIVE_AREA_LEVEL_2, + ADMINISTRATIVE_AREA_LEVEL_3, + ADMINISTRATIVE_AREA_LEVEL_4, + ADMINISTRATIVE_AREA_LEVEL_5, + AIRPORT, + AMUSEMENT_PARK, + AQUARIUM, + ARCHIPELAGO, + ART_GALLERY, + ATM, + BAKERY, + BANK, + BAR, + BEAUTY_SALON, + BICYCLE_STORE, + BOOK_STORE, + BOWLING_ALLEY, + BUS_STATION, + CAFE, + CAMPGROUND, + CAR_DEALER, + CAR_RENTAL, + CAR_REPAIR, + CAR_WASH, + CASINO, + CEMETERY, + CHURCH, + CITY_HALL, + CLOTHING_STORE, + COLLOQUIAL_AREA, + CONTINENT, + CONVENIENCE_STORE, + COUNTRY, + COURTHOUSE, + DENTIST, + DEPARTMENT_STORE, + DOCTOR, + DRUGSTORE, + ELECTRICIAN, + ELECTRONICS_STORE, + EMBASSY, + ESTABLISHMENT, + FINANCE, + FIRE_STATION, + FLOOR, + FLORIST, + FOOD, + FUNERAL_HOME, + FURNITURE_STORE, + GAS_STATION, + GENERAL_CONTRACTOR, + GEOCODE, + GROCERY_OR_SUPERMARKET, + GYM, + HAIR_CARE, + HARDWARE_STORE, + HEALTH, + HINDU_TEMPLE, + HOME_GOODS_STORE, + HOSPITAL, + INSURANCE_AGENCY, + INTERSECTION, + JEWELRY_STORE, + LAUNDRY, + LAWYER, + LIBRARY, + LIGHT_RAIL_STATION, + LIQUOR_STORE, + LOCAL_GOVERNMENT_OFFICE, + LOCALITY, + LOCKSMITH, + LODGING, + MEAL_DELIVERY, + MEAL_TAKEAWAY, + MOSQUE, + MOVIE_RENTAL, + MOVIE_THEATER, + MOVING_COMPANY, + MUSEUM, + NATURAL_FEATURE, + NEIGHBORHOOD, + NIGHT_CLUB, + PAINTER, + PARK, + PARKING, + PET_STORE, + PHARMACY, + PHYSIOTHERAPIST, + PLACE_OF_WORSHIP, + PLUMBER, + PLUS_CODE, + POINT_OF_INTEREST, + POLICE, + POLITICAL, + POST_BOX, + POST_OFFICE, + POSTAL_CODE_PREFIX, + POSTAL_CODE_SUFFIX, + POSTAL_CODE, + POSTAL_TOWN, + PREMISE, + PRIMARY_SCHOOL, + REAL_ESTATE_AGENCY, + RESTAURANT, + ROOFING_CONTRACTOR, + ROOM, + ROUTE, + RV_PARK, + SCHOOL, + SECONDARY_SCHOOL, + SHOE_STORE, + SHOPPING_MALL, + SPA, + STADIUM, + STORAGE, + STORE, + STREET_ADDRESS, + STREET_NUMBER, + SUBLOCALITY_LEVEL_1, + SUBLOCALITY_LEVEL_2, + SUBLOCALITY_LEVEL_3, + SUBLOCALITY_LEVEL_4, + SUBLOCALITY_LEVEL_5, + SUBLOCALITY, + SUBPREMISE, + SUBWAY_STATION, + SUPERMARKET, + SYNAGOGUE, + TAXI_STAND, + TOURIST_ATTRACTION, + TOWN_SQUARE, + TRAIN_STATION, + TRANSIT_STATION, + TRAVEL_AGENCY, + UNIVERSITY, + VETERINARY_CARE, + ZOO; + + companion object { + fun valueOfOrNull(value: String): AutocompletePlaceType? { + try { + return valueOf(value) + } catch (e: IllegalArgumentException) { + return null + } + } + } +} + +data class PlaceWithBounds(val latLng: LatLng, val viewport: LatLngBounds?) \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/autocomplete/MapboxAutocompleteProvider.kt b/app/src/main/java/net/vonforst/evmap/autocomplete/MapboxAutocompleteProvider.kt new file mode 100644 index 00000000..7bd466b1 --- /dev/null +++ b/app/src/main/java/net/vonforst/evmap/autocomplete/MapboxAutocompleteProvider.kt @@ -0,0 +1,115 @@ +package net.vonforst.evmap.autocomplete + +import android.content.Context +import android.graphics.Typeface +import android.text.Spannable +import android.text.SpannableString +import android.text.style.CharacterStyle +import android.text.style.StyleSpan +import com.car2go.maps.model.LatLng +import com.car2go.maps.model.LatLngBounds +import com.car2go.maps.util.SphericalUtil +import com.mapbox.api.geocoding.v5.GeocodingCriteria +import com.mapbox.api.geocoding.v5.MapboxGeocoding +import com.mapbox.api.geocoding.v5.models.CarmenFeature +import com.mapbox.geojson.BoundingBox +import com.mapbox.geojson.Point +import net.vonforst.evmap.R +import java.io.IOException +import kotlin.math.roundToInt + +class MapboxAutocompleteProvider(val context: Context) : AutocompleteProvider { + private val bold: CharacterStyle = StyleSpan(Typeface.BOLD) + private val results = HashMap() + + override fun autocomplete(query: String, location: LatLng?): List { + val result = MapboxGeocoding.builder().apply { + location?.let { + proximity(Point.fromLngLat(location.longitude, location.latitude)) + } + accessToken(context.getString(R.string.mapbox_key)) + autocomplete(true) + this.query(query) + }.build().executeCall() + if (!result.isSuccessful) { + throw IOException(result.message()) + } + return result.body()!!.features().map { feature -> + results[feature.id()!!] = feature + val primaryText = + (feature.matchingText() ?: feature.text())!! + (feature.address()?.let { " $it" } + ?: "") + val secondaryText = + (feature.matchingPlaceName() ?: feature.placeName())!!.replace("$primaryText, ", "") + AutocompletePlace( + highlightMatch(primaryText, query), + secondaryText, + feature.id()!!, + location?.let { location -> + SphericalUtil.computeDistanceBetween( + feature.center()!!.toLatLng(), location + ).roundToInt() + }, + getPlaceTypes(feature) + ) + } + } + + private fun getPlaceTypes(feature: CarmenFeature): List { + val types = feature.placeType()?.mapNotNull { + when (it) { + GeocodingCriteria.TYPE_COUNTRY -> AutocompletePlaceType.COUNTRY + GeocodingCriteria.TYPE_REGION -> AutocompletePlaceType.ADMINISTRATIVE_AREA_LEVEL_1 + GeocodingCriteria.TYPE_POSTCODE -> AutocompletePlaceType.POSTAL_CODE + GeocodingCriteria.TYPE_DISTRICT -> AutocompletePlaceType.ADMINISTRATIVE_AREA_LEVEL_2 + GeocodingCriteria.TYPE_PLACE -> AutocompletePlaceType.ADMINISTRATIVE_AREA_LEVEL_3 + GeocodingCriteria.TYPE_LOCALITY -> AutocompletePlaceType.LOCALITY + GeocodingCriteria.TYPE_NEIGHBORHOOD -> AutocompletePlaceType.NEIGHBORHOOD + GeocodingCriteria.TYPE_ADDRESS -> AutocompletePlaceType.STREET_ADDRESS + GeocodingCriteria.TYPE_POI -> AutocompletePlaceType.POINT_OF_INTEREST + GeocodingCriteria.TYPE_POI_LANDMARK -> AutocompletePlaceType.POINT_OF_INTEREST + else -> null + } + } ?: emptyList() + val categories = feature.properties()?.get("category")?.asString?.split(", ")?.mapNotNull { + // Place categories are defined at https://docs.mapbox.com/api/search/geocoding/#point-of-interest-category-coverage + // We try to find a matching entry in the enum. + // TODO: map categories that are not named the same + AutocompletePlaceType.valueOfOrNull(it.uppercase().replace(" ", "_")) + } ?: emptyList() + return types + categories + } + + private fun highlightMatch(text: String, query: String): CharSequence { + val result = SpannableString(text) + + val startPos = text.lowercase().indexOf(query.lowercase()) + if (startPos > -1) { + val endPos = startPos + query.length + result.setSpan(bold, startPos, endPos, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + return result + } + + override suspend fun getDetails(id: String): PlaceWithBounds { + val place = results[id]!! + results.clear() + return PlaceWithBounds( + place.center()!!.toLatLng(), + place.geometry()?.bbox()?.toLatLngBounds() + ) + } + + override fun getAttributionString(): Int = R.string.powered_by_mapbox + + override fun getAttributionImage(dark: Boolean): Int = R.drawable.mapbox_logo_icon +} + +private fun BoundingBox.toLatLngBounds(): LatLngBounds { + return LatLngBounds( + southwest().toLatLng(), + northeast().toLatLng() + ) +} + +private fun Point.toLatLng(): LatLng = LatLng(this.latitude(), this.longitude()) \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/fragment/MapFragment.kt b/app/src/main/java/net/vonforst/evmap/fragment/MapFragment.kt index 81ad124c..ffeeae57 100644 --- a/app/src/main/java/net/vonforst/evmap/fragment/MapFragment.kt +++ b/app/src/main/java/net/vonforst/evmap/fragment/MapFragment.kt @@ -2,15 +2,17 @@ package net.vonforst.evmap.fragment import android.Manifest.permission.ACCESS_FINE_LOCATION import android.annotation.SuppressLint -import android.app.Activity -import android.content.Intent +import android.content.Context import android.content.pm.PackageManager import android.content.res.Configuration import android.graphics.Color import android.location.Geocoder import android.location.Location import android.os.Bundle +import android.text.method.KeyListener import android.view.* +import android.view.inputmethod.InputMethodManager +import android.widget.AdapterView import android.widget.ImageView import android.widget.TextView import android.widget.Toast @@ -20,9 +22,7 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat -import androidx.core.view.MenuCompat -import androidx.core.view.doOnNextLayout -import androidx.core.view.updateLayoutParams +import androidx.core.view.* import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels @@ -69,9 +69,10 @@ import net.vonforst.evmap.* import net.vonforst.evmap.adapter.ConnectorAdapter import net.vonforst.evmap.adapter.DetailsAdapter import net.vonforst.evmap.adapter.GalleryAdapter +import net.vonforst.evmap.adapter.PlaceAutocompleteAdapter import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper -import net.vonforst.evmap.autocomplete.handleAutocompleteResult -import net.vonforst.evmap.autocomplete.launchAutocomplete +import net.vonforst.evmap.autocomplete.ApiUnavailableException +import net.vonforst.evmap.autocomplete.PlaceWithBounds import net.vonforst.evmap.databinding.FragmentMapBinding import net.vonforst.evmap.model.* import net.vonforst.evmap.storage.PreferenceDataSource @@ -82,9 +83,9 @@ import net.vonforst.evmap.ui.getMarkerTint import net.vonforst.evmap.utils.boundingBox import net.vonforst.evmap.utils.distanceBetween import net.vonforst.evmap.viewmodel.* +import java.io.IOException -const val REQUEST_AUTOCOMPLETE = 2 const val ARG_CHARGER_ID = "chargerId" const val ARG_LAT = "lat" const val ARG_LON = "lon" @@ -120,6 +121,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac return } + if (binding.search.hasFocus()) { + removeSearchFocus() + } + val state = bottomSheetBehavior.state if (state != STATE_COLLAPSED && state != STATE_HIDDEN) { bottomSheetBehavior.state = STATE_COLLAPSED @@ -301,9 +306,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac binding.detailView.topPart.setOnClickListener { bottomSheetBehavior.state = BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT } - binding.search.setOnClickListener { - launchAutocomplete(this, vm.location.value) - } + setupSearchAutocomplete() binding.detailAppBar.toolbar.setNavigationOnClickListener { bottomSheetBehavior.state = STATE_COLLAPSED } @@ -340,6 +343,68 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac } } + var searchKeyListener: KeyListener? = null + + @SuppressLint("SetTextI18n") + private fun setupSearchAutocomplete() { + binding.search.threshold = 1 + + searchKeyListener = binding.search.keyListener + binding.search.keyListener = null + + val adapter = PlaceAutocompleteAdapter(requireContext(), vm.location) + binding.search.setAdapter(adapter) + binding.search.onItemClickListener = + AdapterView.OnItemClickListener { _, _, position, _ -> + val place = adapter.getItem(position) ?: return@OnItemClickListener + lifecycleScope.launch { + try { + vm.searchResult.value = adapter.currentProvider!!.getDetails(place.id) + } catch (e: ApiUnavailableException) { + e.printStackTrace() + } catch (e: IOException) { + // TODO: show error + e.printStackTrace() + } + } + removeSearchFocus() + binding.search.setText( + if (place.secondaryText.isNotEmpty()) { + "${place.primaryText}, ${place.secondaryText}" + } else { + place.primaryText.toString() + } + ) + } + binding.search.onFocusChangeListener = View.OnFocusChangeListener { v, hasFocus -> + if (hasFocus) { + binding.search.keyListener = searchKeyListener + if (binding.search.text.isNotEmpty() && isVisible) { + binding.search.showDropDown() + } + } else { + binding.search.keyListener = null + } + updateBackPressedCallback() + } + binding.clearSearch.setOnClickListener { + vm.searchResult.value = null + removeSearchFocus() + } + binding.toolbar.doOnLayout { + binding.search.dropDownWidth = binding.toolbar.width + binding.search.dropDownAnchor = R.id.toolbar + } + } + + private fun removeSearchFocus() { + // clear focus and hide keyboard + val imm = + requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(binding.search.windowToken, 0) + binding.search.clearFocus() + } + private fun openLayersMenu() { binding.fabLayers.tag = false val materialTransform = MaterialContainerTransform().apply { @@ -464,6 +529,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac .icon(searchResultIcon) .anchor(0.5f, 1f) ) + } else { + binding.search.setText("") } updateBackPressedCallback() @@ -488,6 +555,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac vm.bottomSheetState.value != null && vm.bottomSheetState.value != STATE_HIDDEN || vm.searchResult.value != null || (vm.layersMenuOpen.value ?: false) + || binding.search.hasFocus() } private fun unhighlightAllMarkers() { @@ -1077,17 +1145,6 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac } } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - when (requestCode) { - REQUEST_AUTOCOMPLETE -> { - if (resultCode == Activity.RESULT_OK && data != null) { - vm.searchResult.value = handleAutocompleteResult(data) - } - } - else -> super.onActivityResult(requestCode, resultCode, data) - } - } - override fun getRootView(): View { return binding.root } diff --git a/app/src/main/java/net/vonforst/evmap/fragment/SettingsFragment.kt b/app/src/main/java/net/vonforst/evmap/fragment/SettingsFragment.kt index c5e9de92..a42bbdf1 100644 --- a/app/src/main/java/net/vonforst/evmap/fragment/SettingsFragment.kt +++ b/app/src/main/java/net/vonforst/evmap/fragment/SettingsFragment.kt @@ -3,6 +3,7 @@ package net.vonforst.evmap.fragment import android.content.SharedPreferences import android.os.Bundle import android.view.View +import android.widget.Toast import androidx.appcompat.widget.Toolbar import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController @@ -122,6 +123,12 @@ class SettingsFragment : PreferenceFragmentCompat(), "chargeprice_my_tariffs" -> { updateMyTariffsSummary() } + "search_provider" -> { + if (prefs.searchProvider == "google") { + Toast.makeText(context, R.string.pref_search_provider_info, Toast.LENGTH_LONG) + .show() + } + } } } diff --git a/app/src/main/java/net/vonforst/evmap/storage/PreferenceDataSource.kt b/app/src/main/java/net/vonforst/evmap/storage/PreferenceDataSource.kt index 26ab682f..e8dac2bc 100644 --- a/app/src/main/java/net/vonforst/evmap/storage/PreferenceDataSource.kt +++ b/app/src/main/java/net/vonforst/evmap/storage/PreferenceDataSource.kt @@ -80,6 +80,12 @@ class PreferenceDataSource(val context: Context) { context.getString(R.string.pref_map_provider_default) )!! + val searchProvider: String + get() = sp.getString( + "search_provider", + context.getString(R.string.pref_search_provider_default) + )!! + var mapType: AnyMap.Type get() = AnyMap.Type.valueOf(sp.getString("map_type", null) ?: AnyMap.Type.NORMAL.toString()) set(type) { diff --git a/app/src/main/java/net/vonforst/evmap/ui/BindingAdapters.kt b/app/src/main/java/net/vonforst/evmap/ui/BindingAdapters.kt index ebaf0061..fb4ddf66 100644 --- a/app/src/main/java/net/vonforst/evmap/ui/BindingAdapters.kt +++ b/app/src/main/java/net/vonforst/evmap/ui/BindingAdapters.kt @@ -23,6 +23,9 @@ import com.google.android.material.slider.RangeSlider import net.vonforst.evmap.R import net.vonforst.evmap.api.availability.ChargepointStatus import net.vonforst.evmap.api.iconForPlugType +import net.vonforst.evmap.kmPerMile +import net.vonforst.evmap.meterPerFt +import net.vonforst.evmap.shouldUseImperialUnits import kotlin.math.ceil import kotlin.math.floor import kotlin.math.roundToInt @@ -78,16 +81,34 @@ fun invisibleUnlessAnimated(view: View, oldValue: Boolean, newValue: Boolean) { @BindingAdapter("isFabActive") fun isFabActive(view: FloatingActionButton, isColored: Boolean) { - val color = view.context.theme.obtainStyledAttributes( + view.imageTintList = activeTint(view.context, isColored) +} + +@BindingAdapter("backgroundTintActive") +fun backgroundTintActive(view: View, isColored: Boolean) { + view.backgroundTintList = activeTint(view.context, isColored) +} + +@BindingAdapter("imageTintActive") +fun imageTintActive(view: ImageView, isColored: Boolean) { + view.imageTintList = activeTint(view.context, isColored) +} + +private fun activeTint( + context: Context, + isColored: Boolean +): ColorStateList { + val color = context.theme.obtainStyledAttributes( intArrayOf( if (isColored) { - R.attr.colorAccent + R.attr.colorPrimary } else { R.attr.colorControlNormal } ) ) - view.imageTintList = ColorStateList.valueOf(color.getColor(0, 0)) + val valueOf = ColorStateList.valueOf(color.getColor(0, 0)) + return valueOf } @BindingAdapter("data") @@ -275,6 +296,26 @@ fun time(value: Int): String { else "%d:%02d h".format(h, min); } +fun distance(meters: Number?): String? { + if (meters == null) return null + if (shouldUseImperialUnits()) { + val ft = meters.toDouble() / meterPerFt + val mi = meters.toDouble() / 1e3 / kmPerMile + return when { + ft < 1000 -> "%.0f ft".format(ft) + mi < 10 -> "%.1f mi".format(mi) + else -> "%.0f mi".format(mi) + } + } else { + val km = meters.toDouble() / 1e3 + return when { + km < 1 -> "%.0f m".format(meters.toDouble()) + km < 10 -> "%.1f km".format(km) + else -> "%.0f km".format(km) + } + } +} + @InverseBindingAdapter(attribute = "app:values") fun getRangeSliderValue(slider: RangeSlider) = slider.values diff --git a/app/src/main/java/net/vonforst/evmap/viewmodel/FavoritesViewModel.kt b/app/src/main/java/net/vonforst/evmap/viewmodel/FavoritesViewModel.kt index 7338501c..186e7d6f 100644 --- a/app/src/main/java/net/vonforst/evmap/viewmodel/FavoritesViewModel.kt +++ b/app/src/main/java/net/vonforst/evmap/viewmodel/FavoritesViewModel.kt @@ -66,7 +66,7 @@ class FavoritesViewModel(application: Application, geApiKey: String) : loc.longitude, charger.coordinates.lat, charger.coordinates.lng - ) / 1000 + ) } }) }?.sortedBy { it.distance } diff --git a/app/src/main/java/net/vonforst/evmap/viewmodel/MapViewModel.kt b/app/src/main/java/net/vonforst/evmap/viewmodel/MapViewModel.kt index 1f52d98f..27bf5daf 100644 --- a/app/src/main/java/net/vonforst/evmap/viewmodel/MapViewModel.kt +++ b/app/src/main/java/net/vonforst/evmap/viewmodel/MapViewModel.kt @@ -18,6 +18,7 @@ import net.vonforst.evmap.api.openchargemap.OCMConnection import net.vonforst.evmap.api.openchargemap.OCMReferenceData import net.vonforst.evmap.api.openchargemap.OpenChargeMapApiWrapper import net.vonforst.evmap.api.stringProvider +import net.vonforst.evmap.autocomplete.PlaceWithBounds import net.vonforst.evmap.model.* import net.vonforst.evmap.storage.AppDatabase import net.vonforst.evmap.storage.FilterProfile @@ -27,8 +28,6 @@ import java.io.IOException data class MapPosition(val bounds: LatLngBounds, val zoom: Float) -data class PlaceWithBounds(val latLng: LatLng, val viewport: LatLngBounds?) - internal fun getClusterDistance(zoom: Float): Int? { return when (zoom) { in 0.0..7.0 -> 100 @@ -165,7 +164,7 @@ class MapViewModel(application: Application) : AndroidViewModel(application) { loc.longitude, charger.coordinates.lat, charger.coordinates.lng - ) / 1000 + ) } else null } addSource(chargerSparse, callback) diff --git a/app/src/main/res/drawable/circle_bg_autocomplete.xml b/app/src/main/res/drawable/circle_bg_autocomplete.xml new file mode 100644 index 00000000..827f82e9 --- /dev/null +++ b/app/src/main/res/drawable/circle_bg_autocomplete.xml @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_place_type_airport.xml b/app/src/main/res/drawable/ic_place_type_airport.xml new file mode 100644 index 00000000..b418fdc3 --- /dev/null +++ b/app/src/main/res/drawable/ic_place_type_airport.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_place_type_default.xml b/app/src/main/res/drawable/ic_place_type_default.xml new file mode 100644 index 00000000..67aad5c1 --- /dev/null +++ b/app/src/main/res/drawable/ic_place_type_default.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_place_type_train.xml b/app/src/main/res/drawable/ic_place_type_train.xml new file mode 100644 index 00000000..d6a7fab1 --- /dev/null +++ b/app/src/main/res/drawable/ic_place_type_train.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/detail_view.xml b/app/src/main/res/layout/detail_view.xml index f7ec6e0f..91b76de0 100644 --- a/app/src/main/res/layout/detail_view.xml +++ b/app/src/main/res/layout/detail_view.xml @@ -114,7 +114,7 @@ android:gravity="end" android:maxLines="1" android:minWidth="50dp" - android:text="@{@string/distance_format(distance)}" + android:text="@{BindingAdaptersKt.distance(distance)}" android:textAppearance="@style/TextAppearance.MaterialComponents.Caption" app:layout_constraintBottom_toBottomOf="@+id/txtConnectors" app:layout_constraintEnd_toStartOf="@+id/guideline2" diff --git a/app/src/main/res/layout/fragment_map.xml b/app/src/main/res/layout/fragment_map.xml index b5e8a40c..7cdd6034 100644 --- a/app/src/main/res/layout/fragment_map.xml +++ b/app/src/main/res/layout/fragment_map.xml @@ -58,16 +58,47 @@ + android:layout_height="48dp" + app:contentInsetStartWithNavigation="70dp"> - + android:focusable="true" + android:focusableInTouchMode="true"> + + + + + diff --git a/app/src/main/res/layout/item_autocomplete_attribution.xml b/app/src/main/res/layout/item_autocomplete_attribution.xml new file mode 100644 index 00000000..4e1e2e78 --- /dev/null +++ b/app/src/main/res/layout/item_autocomplete_attribution.xml @@ -0,0 +1,11 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_autocomplete_result.xml b/app/src/main/res/layout/item_autocomplete_result.xml new file mode 100644 index 00000000..09f4fe2e --- /dev/null +++ b/app/src/main/res/layout/item_autocomplete_result.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_favorite.xml b/app/src/main/res/layout/item_favorite.xml index d50f5ae6..0b2a2536 100644 --- a/app/src/main/res/layout/item_favorite.xml +++ b/app/src/main/res/layout/item_favorite.xml @@ -91,7 +91,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" - android:text="@{@string/distance_format(item.distance)}" + android:text="@{BindingAdaptersKt.distance(item.distance)}" android:textAppearance="@style/TextAppearance.MaterialComponents.Caption" app:goneUnless="@{item.distance != null}" app:layout_constraintEnd_toStartOf="@id/btnDelete" diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 74a8a0f8..ec7433b2 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -43,7 +43,6 @@ Datenschutzerklärung Zu Favoriten hinzufügen Aus Favoriten entfernen - %.1f km Navigation sofort starten Navigationsbutton startet Navigation direkt Navigationsbutton startet Karten-App mit Position der Ladesäule @@ -233,4 +232,7 @@ Und los Sorry, anscheinend ist EVMap abgestürzt. Bitte schicke einen Fehlerbericht an den Entwickler. Du kannst unten noch einen Kommentar hinzufügen: + powered by Mapbox + Anbieter für Ortssuche + Spenden\" sehr freuen.]]> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6329c991..e2dc4fc2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -42,7 +42,6 @@ Privacy Notice Add to favorites Remove from favorites - %.1f km Start navigation immediately Navigation button starts navigation immediately Navigation button launches maps app with charger location @@ -218,4 +217,7 @@ Let\'s go Sorry, it seems that EVMap has crashed. Please send a crash report to the developer. You can add a comment below: + powered by Mapbox + Place search provider + Donate\".]]> diff --git a/app/src/main/res/xml/settings.xml b/app/src/main/res/xml/settings.xml index 0352d0cb..7ed57267 100644 --- a/app/src/main/res/xml/settings.xml +++ b/app/src/main/res/xml/settings.xml @@ -39,6 +39,14 @@ android:defaultValue="@string/pref_map_provider_default" android:summary="%s" /> + +