mirror of
https://github.com/ev-map/EVMap.git
synced 2026-04-23 07:36:59 -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:
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)
|
||||
|
||||
Reference in New Issue
Block a user