mirror of
https://github.com/ev-map/EVMap.git
synced 2026-04-30 11:04:16 -04:00
Merge pull request #120 from johan12345/new-autocomplete-ui
New place search UI supporting both Mapbox and Google Places
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package net.vonforst.evmap.autocomplete
|
||||
|
||||
import android.content.Context
|
||||
|
||||
fun getAutocompleteProviders(context: Context) = listOf(MapboxAutocompleteProvider(context))
|
||||
@@ -3,6 +3,12 @@
|
||||
<string-array name="pref_map_provider_names">
|
||||
<item>OpenStreetMap (Mapbox)</item>
|
||||
</string-array>
|
||||
<string-array name="pref_search_provider_names">
|
||||
<item>OpenStreetMap (Mapbox)</item>
|
||||
</string-array>
|
||||
<string-array name="pref_search_provider_values" tranlatable="false">
|
||||
<item>mapbox</item>
|
||||
</string-array>
|
||||
<string name="donations_info" formatted="false">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.</string>
|
||||
<string name="donate_paypal">Mit PayPal spenden</string>
|
||||
</resources>
|
||||
@@ -6,6 +6,13 @@
|
||||
<string-array name="pref_map_provider_values" tranlatable="false">
|
||||
<item>mapbox</item>
|
||||
</string-array>
|
||||
<string-array name="pref_search_provider_names">
|
||||
<item>OpenStreetMap (Mapbox)</item>
|
||||
</string-array>
|
||||
<string-array name="pref_search_provider_values" tranlatable="false">
|
||||
<item>mapbox</item>
|
||||
</string-array>
|
||||
<string name="pref_search_provider_default" translatable="false">mapbox</string>
|
||||
<string name="pref_map_provider_default" translatable="false">mapbox</string>
|
||||
<string name="donations_info" formatted="false">Do you find EVMap useful? Support its development by sending a donation to the developer.</string>
|
||||
<string name="donate_paypal">Donate with PayPal</string>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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<AutocompletePlace> {
|
||||
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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,10 @@
|
||||
<item>Google Maps</item>
|
||||
<item>OpenStreetMap (Mapbox)</item>
|
||||
</string-array>
|
||||
<string-array name="pref_search_provider_names">
|
||||
<item>Google Maps</item>
|
||||
<item>OpenStreetMap (Mapbox)</item>
|
||||
</string-array>
|
||||
<string name="donations_info" formatted="false">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.</string>
|
||||
<string name="auto_location_service">EVMap läuft unter Android Auto und nutzt dafür deinen Standort.</string>
|
||||
<string name="auto_no_chargers_found">Keine Ladestationen in der Nähe gefunden</string>
|
||||
|
||||
@@ -8,7 +8,16 @@
|
||||
<item>google</item>
|
||||
<item>mapbox</item>
|
||||
</string-array>
|
||||
<string-array name="pref_search_provider_names">
|
||||
<item>Google Maps</item>
|
||||
<item>OpenStreetMap (Mapbox)</item>
|
||||
</string-array>
|
||||
<string-array name="pref_search_provider_values" tranlatable="false">
|
||||
<item>google</item>
|
||||
<item>mapbox</item>
|
||||
</string-array>
|
||||
<string name="pref_map_provider_default" translatable="false">google</string>
|
||||
<string name="pref_search_provider_default" translatable="false">mapbox</string>
|
||||
<string name="donations_info" formatted="false">Do you find EVMap useful? Support its development by sending a donation to the developer.\n\nGoogle takes 15% off every donation.</string>
|
||||
<string name="auto_location_service">EVMap is running on Android Auto and using your location.</string>
|
||||
<string name="auto_no_chargers_found">No nearby chargers found</string>
|
||||
|
||||
321
app/src/main/java/android/widget/Filter.java
Normal file
321
app/src/main/java/android/widget/Filter.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>A filter constrains data with a filtering pattern.</p>
|
||||
*
|
||||
* <p>Filters are usually created by {@link android.widget.Filterable}
|
||||
* classes.</p>
|
||||
*
|
||||
* <p>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.</p>
|
||||
*
|
||||
* @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();
|
||||
|
||||
/**
|
||||
* <p>Creates a new asynchronous filter.</p>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>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.</p>
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>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.</p>
|
||||
*
|
||||
* <p>Upon completion, the listener is notified.</p>
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>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)}.</p>
|
||||
*
|
||||
* <p><strong>Contract:</strong> When the constraint is null, the original
|
||||
* data must be restored.</p>
|
||||
*
|
||||
* @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);
|
||||
|
||||
/**
|
||||
* <p>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}.</p>
|
||||
*
|
||||
* @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);
|
||||
|
||||
/**
|
||||
* <p>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.</p>
|
||||
*
|
||||
* @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();
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Holds the results of a filtering operation. The results are the values
|
||||
* computed by the filtering operation and the number of these values.</p>
|
||||
*/
|
||||
protected static class FilterResults {
|
||||
public FilterResults() {
|
||||
// nothing to see here
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Contains all the values computed by the filtering operation.</p>
|
||||
*/
|
||||
public Object values;
|
||||
|
||||
/**
|
||||
* <p>Contains the number of values computed by the filtering
|
||||
* operation.</p>
|
||||
*/
|
||||
public int count;
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Listener used to receive a notification upon completion of a filtering
|
||||
* operation.</p>
|
||||
*/
|
||||
public static interface FilterListener {
|
||||
/**
|
||||
* <p>Notifies the end of a filtering operation.</p>
|
||||
*
|
||||
* @param count the number of values computed by the filter
|
||||
*/
|
||||
public void onFilterComplete(int count);
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>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.</p>
|
||||
*/
|
||||
private class RequestHandler extends Handler {
|
||||
public RequestHandler(Looper looper) {
|
||||
super(looper);
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Handles filtering requests by calling
|
||||
* {@link Filter#performFiltering} and then sending a message
|
||||
* with the results to the results handler.</p>
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Handles the results of a filtering operation. The results are
|
||||
* handled in the UI thread.</p>
|
||||
*/
|
||||
private class ResultsHandler extends Handler {
|
||||
/**
|
||||
* <p>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.</p>
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Holds the arguments of a filtering request as well as the results
|
||||
* of the request.</p>
|
||||
*/
|
||||
private static class RequestArguments {
|
||||
/**
|
||||
* <p>The constraint used to filter the data.</p>
|
||||
*/
|
||||
CharSequence constraint;
|
||||
|
||||
/**
|
||||
* <p>The listener to notify upon completion. Can be null.</p>
|
||||
*/
|
||||
FilterListener listener;
|
||||
|
||||
/**
|
||||
* <p>The results of the filtering operation.</p>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 <T> List<T>.containsAny(vararg values: T) = values.any { this.contains(it) }
|
||||
|
||||
public suspend fun <T> LiveData<T>.await(): T {
|
||||
return withContext(Dispatchers.Main.immediate) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
@@ -97,4 +102,14 @@ public suspend fun <T> LiveData<T>.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")
|
||||
}
|
||||
@@ -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<LatLng>) :
|
||||
BaseAdapter(), Filterable {
|
||||
private var resultList: List<AutocompletePlace>? = 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<AutocompletePlaceType>): 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<AutocompletePlaceType>): Boolean =
|
||||
iconForPlaceType(types) != R.drawable.ic_place_type_default
|
||||
@@ -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())
|
||||
@@ -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<AutocompletePlace>
|
||||
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<AutocompletePlaceType>
|
||||
)
|
||||
|
||||
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?)
|
||||
@@ -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<String, CarmenFeature>()
|
||||
|
||||
override fun autocomplete(query: String, location: LatLng?): List<AutocompletePlace> {
|
||||
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<AutocompletePlaceType> {
|
||||
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())
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ class FavoritesViewModel(application: Application, geApiKey: String) :
|
||||
loc.longitude,
|
||||
charger.coordinates.lat,
|
||||
charger.coordinates.lng
|
||||
) / 1000
|
||||
)
|
||||
}
|
||||
})
|
||||
}?.sortedBy { it.distance }
|
||||
|
||||
@@ -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)
|
||||
|
||||
11
app/src/main/res/drawable/circle_bg_autocomplete.xml
Normal file
11
app/src/main/res/drawable/circle_bg_autocomplete.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<shape android:shape="oval">
|
||||
<solid android:color="#33FFFFFF" />
|
||||
<size
|
||||
android:height="24dp"
|
||||
android:width="24dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
10
app/src/main/res/drawable/ic_place_type_airport.xml
Normal file
10
app/src/main/res/drawable/ic_place_type_airport.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M22,16v-2l-8.5,-5V3.5C13.5,2.67 12.83,2 12,2s-1.5,0.67 -1.5,1.5V9L2,14v2l8.5,-2.5V19L8,20.5L8,22l4,-1l4,1l0,-1.5L13.5,19v-5.5L22,16z" />
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/ic_place_type_default.xml
Normal file
10
app/src/main/res/drawable/ic_place_type_default.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/ic_place_type_train.xml
Normal file
10
app/src/main/res/drawable/ic_place_type_train.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,2c-4,0 -8,0.5 -8,4v9.5C4,17.43 5.57,19 7.5,19L6,20.5v0.5h2.23l2,-2L14,19l2,2h2v-0.5L16.5,19c1.93,0 3.5,-1.57 3.5,-3.5L20,6c0,-3.5 -3.58,-4 -8,-4zM7.5,17c-0.83,0 -1.5,-0.67 -1.5,-1.5S6.67,14 7.5,14s1.5,0.67 1.5,1.5S8.33,17 7.5,17zM11,10L6,10L6,6h5v4zM13,10L13,6h5v4h-5zM16.5,17c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5z" />
|
||||
</vector>
|
||||
@@ -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"
|
||||
|
||||
@@ -58,16 +58,47 @@
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp">
|
||||
android:layout_height="48dp"
|
||||
app:contentInsetStartWithNavigation="70dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/search"
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:text="@string/search"
|
||||
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
|
||||
android:textColor="?android:textColorSecondary" />
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true">
|
||||
|
||||
<AutoCompleteTextView
|
||||
android:id="@+id/search"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="match_parent"
|
||||
android:singleLine="true"
|
||||
android:scrollHorizontally="true"
|
||||
android:ellipsize="end"
|
||||
android:background="@null"
|
||||
android:gravity="center_vertical"
|
||||
android:hint="@string/search"
|
||||
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
android:dropDownVerticalOffset="8dp"
|
||||
android:popupBackground="@drawable/rounded_rect_4dp" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/clearSearch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
app:invisibleUnless="@{search.text.length() > 0}"
|
||||
app:tint="?colorControlNormal"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/handle"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/ic_close"
|
||||
android:contentDescription="@string/delete" />
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.appcompat.widget.Toolbar>
|
||||
|
||||
|
||||
11
app/src/main/res/layout/item_autocomplete_attribution.xml
Normal file
11
app/src/main/res/layout/item_autocomplete_attribution.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ImageView android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:paddingStart="24dp"
|
||||
android:paddingTop="18dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingBottom="14dp"
|
||||
android:scaleType="fitStart"
|
||||
tools:src="@drawable/places_powered_by_google_light" />
|
||||
87
app/src/main/res/layout/item_autocomplete_result.xml
Normal file
87
app/src/main/res/layout/item_autocomplete_result.xml
Normal file
@@ -0,0 +1,87 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<data>
|
||||
|
||||
<import type="android.text.util.Linkify" />
|
||||
<import type="net.vonforst.evmap.adapter.PlaceAutocompleteAdapterKt" />
|
||||
<import type="net.vonforst.evmap.ui.BindingAdaptersKt" />
|
||||
|
||||
<variable
|
||||
name="item"
|
||||
type="net.vonforst.evmap.autocomplete.AutocompletePlace" />
|
||||
</data>
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?selectableItemBackground">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="24dp"
|
||||
android:layout_marginTop="18dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="14dp"
|
||||
android:text="@{item.primaryText}"
|
||||
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/icon"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_bias="0.0"
|
||||
tools:text="Lorem ipsum" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/icon"
|
||||
android:layout_width="30dp"
|
||||
android:layout_height="30dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:contentDescription="@{item.secondaryText}"
|
||||
android:padding="6dp"
|
||||
android:background="@drawable/circle_bg_autocomplete"
|
||||
android:backgroundTintMode="src_in"
|
||||
app:imageTintActive="@{PlaceAutocompleteAdapterKt.isSpecialPlace(item.types)}"
|
||||
app:backgroundTintActive="@{PlaceAutocompleteAdapterKt.isSpecialPlace(item.types)}"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@{PlaceAutocompleteAdapterKt.iconForPlaceType(item.types)}"
|
||||
tools:srcCompat="@drawable/ic_address"
|
||||
tools:tint="?colorControlNormal"
|
||||
tools:backgroundTint="?colorControlNormal" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView16"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@{BindingAdaptersKt.distance(item.distanceMeters)}"
|
||||
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
|
||||
app:goneUnless="@{item.distanceMeters != null}"
|
||||
app:layout_constraintEnd_toEndOf="@+id/icon"
|
||||
app:layout_constraintStart_toStartOf="@+id/icon"
|
||||
app:layout_constraintTop_toBottomOf="@+id/icon"
|
||||
tools:text="9999 km" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/subtitle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="14dp"
|
||||
android:text="@{item.secondaryText}"
|
||||
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="@+id/title"
|
||||
app:layout_constraintTop_toBottomOf="@+id/title"
|
||||
tools:text="Lorem ipsum" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</layout>
|
||||
@@ -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"
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
<string name="privacy">Datenschutzerklärung</string>
|
||||
<string name="fav_add">Zu Favoriten hinzufügen</string>
|
||||
<string name="fav_remove">Aus Favoriten entfernen</string>
|
||||
<string name="distance_format">%.1f km</string>
|
||||
<string name="pref_navigate_use_maps">Navigation sofort starten</string>
|
||||
<string name="pref_navigate_use_maps_on">Navigationsbutton startet Navigation direkt</string>
|
||||
<string name="pref_navigate_use_maps_off">Navigationsbutton startet Karten-App mit Position der Ladesäule</string>
|
||||
@@ -233,4 +232,7 @@
|
||||
<string name="lets_go">Und los</string>
|
||||
<string name="crash_report_text">Sorry, anscheinend ist EVMap abgestürzt. Bitte schicke einen Fehlerbericht an den Entwickler.</string>
|
||||
<string name="crash_report_comment_prompt">Du kannst unten noch einen Kommentar hinzufügen:</string>
|
||||
<string name="powered_by_mapbox">powered by Mapbox</string>
|
||||
<string name="pref_search_provider">Anbieter für Ortssuche</string>
|
||||
<string name="pref_search_provider_info"><![CDATA[Die Daten für die Ortssuche, vor allem von Google Maps, sind relativ teuer. Wenn du diese Funktion häufig nutzt, würde ich mich über eine Spende unter \"Über EVMap -> Spenden\" sehr freuen.]]></string>
|
||||
</resources>
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
<string name="privacy">Privacy Notice</string>
|
||||
<string name="fav_add">Add to favorites</string>
|
||||
<string name="fav_remove">Remove from favorites</string>
|
||||
<string name="distance_format">%.1f km</string>
|
||||
<string name="pref_navigate_use_maps">Start navigation immediately</string>
|
||||
<string name="pref_navigate_use_maps_on">Navigation button starts navigation immediately</string>
|
||||
<string name="pref_navigate_use_maps_off">Navigation button launches maps app with charger location</string>
|
||||
@@ -218,4 +217,7 @@
|
||||
<string name="lets_go">Let\'s go</string>
|
||||
<string name="crash_report_text">Sorry, it seems that EVMap has crashed. Please send a crash report to the developer.</string>
|
||||
<string name="crash_report_comment_prompt">You can add a comment below:</string>
|
||||
<string name="powered_by_mapbox">powered by Mapbox</string>
|
||||
<string name="pref_search_provider">Place search provider</string>
|
||||
<string name="pref_search_provider_info"><![CDATA[Data for place search, especially from Google Maps, is relatively expensive. If you use this feature often, please consider making a donation through \"About EVMap -> Donate\".]]></string>
|
||||
</resources>
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
android:defaultValue="@string/pref_map_provider_default"
|
||||
android:summary="%s" />
|
||||
|
||||
<ListPreference
|
||||
android:key="search_provider"
|
||||
android:title="@string/pref_search_provider"
|
||||
android:entries="@array/pref_search_provider_names"
|
||||
android:entryValues="@array/pref_search_provider_values"
|
||||
android:defaultValue="@string/pref_search_provider_default"
|
||||
android:summary="%s" />
|
||||
|
||||
<CheckBoxPreference
|
||||
android:key="navigate_use_maps"
|
||||
android:title="@string/pref_navigate_use_maps"
|
||||
|
||||
Reference in New Issue
Block a user