Compare commits

..

30 Commits
0.1.2 ... 0.1.8

Author SHA1 Message Date
Johan von Forstner
7759c230db Release 0.1.8 2020-06-15 11:19:11 +02:00
Johan von Forstner
cdc575ff33 add missing libraries causing crash when using the search form 2020-06-15 11:18:43 +02:00
Johan von Forstner
cb250de79e improve openinghours layout 2020-06-14 20:21:13 +02:00
Johan von Forstner
c7885ae729 remove roundet corners at bottom of detail view 2020-06-14 20:07:10 +02:00
Johan von Forstner
024b56952d add unit test for GoingElectric API 2020-06-14 20:01:21 +02:00
Johan von Forstner
75b2240247 Release 0.1.7 2020-06-14 19:21:19 +02:00
Johan von Forstner
d8f011b64b Add error message when internet is not available 2020-06-14 19:19:27 +02:00
Johan von Forstner
a1760a35ff Fix startkey in GE API 2020-06-14 17:48:40 +02:00
Johan von Forstner
e5e5f8ef3c Release 0.1.6 2020-06-14 12:34:36 +02:00
Johan von Forstner
b5a4fe2dc8 Improve filter by number of chargers
- load more pages of GE results
- If server-side clustering is not available, apply local Clustering
2020-06-14 12:33:05 +02:00
Johan von Forstner
676e703a52 upgrade to Google Maps SDK v3 Beta (seems to fix #25) 2020-06-14 11:55:44 +02:00
Johan von Forstner
b9997cbb5a fix exiting with back button 2020-06-13 23:06:45 +02:00
Johan von Forstner
2558052f4f fix charge card filter 2020-06-13 22:58:53 +02:00
Johan von Forstner
980c8cc0af enable Stetho only in debug builds 2020-06-13 22:58:41 +02:00
Johan von Forstner
ffb6740da8 Add language chooser (fixes #24) 2020-06-13 19:52:39 +02:00
Johan von Forstner
2e9112f5c2 Release 0.1.5 2020-06-13 16:44:06 +02:00
Johan von Forstner
3c709fa3c5 add visual and haptic feedback when enabling/disabling filters 2020-06-13 16:19:50 +02:00
Johan von Forstner
11c868af66 remove TODO 2020-06-13 16:08:57 +02:00
Johan von Forstner
e3ea72bac6 implement new selection interface for network and chargecard filters 2020-06-13 16:03:52 +02:00
Johan von Forstner
d01371f6e9 add filters by network and charge card 2020-06-13 15:48:02 +02:00
Johan von Forstner
6130b190e1 disable/enable filters with long click on filter view 2020-06-13 08:04:06 +02:00
johan12345
128d156306 Release 0.1.4 2020-06-01 22:16:30 +02:00
johan12345
f855874d56 fix changed transition API 2020-06-01 22:08:56 +02:00
johan12345
92ebf6c1e5 update some libraries 2020-06-01 21:47:23 +02:00
Johan von Forstner
1e98be0f8f implement full display for opening hours (fixes #23) 2020-06-01 21:34:57 +02:00
Johan von Forstner
c0bec92d4c update Gradle plugin and Kotlin version 2020-06-01 16:35:25 +02:00
Johan von Forstner
71ecd492e9 show error dialog when Google Play Services are not available 2020-05-30 16:25:13 +02:00
Johan von Forstner
fcac8f91ad do not use white nav bar before Android API 27
(otherwise nav buttons are not visible)
2020-05-30 16:07:13 +02:00
johan12345
795c96d901 Release 0.1.3 2020-05-28 09:03:02 +02:00
johan12345
cc76310b2b fix string 2020-05-28 09:02:13 +02:00
51 changed files with 1582 additions and 170 deletions

View File

@@ -13,8 +13,8 @@ android {
applicationId "net.vonforst.evmap"
minSdkVersion 21
targetSdkVersion 29
versionCode 10
versionName "0.1.2"
versionCode 16
versionName "0.1.8"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@@ -80,18 +80,14 @@ dependencies {
implementation "androidx.activity:activity-ktx:1.1.0"
implementation "androidx.fragment:fragment-ktx:1.2.4"
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.core:core:1.3.0-rc01'
implementation 'androidx.core:core:1.3.0'
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.preference:preference-ktx:1.1.1'
implementation 'com.google.android.material:material:1.2.0-alpha06'
implementation 'com.google.android.material:material:1.2.0-beta01'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.recyclerview:recyclerview:1.1.0'
implementation 'androidx.browser:browser:1.2.0'
implementation 'com.google.maps.android:android-maps-utils:0.5'
implementation 'com.github.johan12345:CustomBottomSheetBehavior:f69f532660'
implementation 'com.google.android.gms:play-services-maps:17.0.0'
implementation 'com.google.android.gms:play-services-location:17.0.0'
implementation 'com.google.android.libraries.places:places:2.2.0'
implementation 'com.squareup.retrofit2:retrofit:2.7.2'
implementation 'com.squareup.retrofit2:converter-moshi:2.7.2'
implementation 'com.squareup.moshi:moshi-kotlin:1.9.2'
@@ -102,8 +98,21 @@ dependencies {
implementation 'com.airbnb.android:lottie:3.4.0'
implementation 'io.michaelrocks:bimap:1.0.2'
// Google Maps v3 Beta
implementation name:'maps-sdk-3.0.0-beta', ext:'aar'
implementation name:'places-maps-sdk-3.0.0-beta', ext:'aar'
implementation 'com.google.maps.android:android-maps-utils-v3:1.3.3'
implementation 'com.google.android.gms:play-services-basement:17.3.0'
implementation 'com.google.android.gms:play-services-base:17.3.0'
implementation 'com.google.android.gms:play-services-gcm:17.0.0'
implementation 'com.google.android.gms:play-services-location:17.0.0'
implementation 'com.google.android.gms:play-services-clearcut:17.0.0'
implementation 'com.android.volley:volley:1.1.1'
implementation 'com.google.code.gson:gson:2.8.6'
implementation 'com.github.bumptech.glide:glide:4.11.0'
// navigation library
def nav_version = "2.3.0-alpha06"
def nav_version = "2.3.0-beta01"
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
@@ -119,7 +128,7 @@ dependencies {
implementation "androidx.room:room-ktx:$room_version"
// billing library
def billing_version = "2.2.0"
def billing_version = "2.2.1"
implementation "com.android.billingclient:billing:$billing_version"
implementation "com.android.billingclient:billing-ktx:$billing_version"

View File

Binary file not shown.

View File

Binary file not shown.

View File

@@ -1,8 +1,10 @@
package net.vonforst.evmap
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.browser.customtabs.CustomTabsIntent
@@ -12,10 +14,14 @@ import androidx.navigation.NavController
import androidx.navigation.findNavController
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupWithNavController
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.utils.LocaleContextWrapper
const val REQUEST_LOCATION_PERMISSION = 1
@@ -30,6 +36,14 @@ class MapsActivity : AppCompatActivity() {
var fragmentCallback: FragmentCallback? = null
private lateinit var prefs: PreferenceDataSource
override fun attachBaseContext(newBase: Context) {
return super.attachBaseContext(
LocaleContextWrapper.wrap(
newBase, PreferenceDataSource(newBase).language
)
);
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -47,6 +61,8 @@ class MapsActivity : AppCompatActivity() {
findViewById<NavigationView>(R.id.nav_view).setupWithNavController(navController)
prefs = PreferenceDataSource(this)
checkPlayServices()
}
fun navigateTo(charger: ChargeLocation) {
@@ -92,4 +108,19 @@ class MapsActivity : AppCompatActivity() {
}
startActivity(intent)
}
private fun checkPlayServices(): Boolean {
val request = 9000
val apiAvailability = GoogleApiAvailability.getInstance()
val resultCode = apiAvailability.isGooglePlayServicesAvailable(this)
if (resultCode != ConnectionResult.SUCCESS) {
if (apiAvailability.isUserResolvableError(resultCode)) {
apiAvailability.getErrorDialog(this, resultCode, request).show()
} else {
Log.d("EVMap", "This device is not supported.")
}
return false
}
return true
}
}

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.children
import androidx.databinding.DataBindingUtil
import androidx.databinding.Observable
@@ -17,8 +18,11 @@ import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.api.goingelectric.OpeningHoursDays
import net.vonforst.evmap.databinding.ItemFilterMultipleChoiceBinding
import net.vonforst.evmap.databinding.ItemFilterMultipleChoiceLargeBinding
import net.vonforst.evmap.databinding.ItemFilterSliderBinding
import net.vonforst.evmap.fragment.MultiSelectDialog
import net.vonforst.evmap.viewmodel.*
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -28,8 +32,8 @@ interface Equatable {
override fun equals(other: Any?): Boolean;
}
abstract class DataBindingAdapter<T : Equatable>() :
ListAdapter<T, DataBindingAdapter.ViewHolder<T>>(DiffCallback()) {
abstract class DataBindingAdapter<T : Equatable>(getKey: ((T) -> Any)? = null) :
ListAdapter<T, DataBindingAdapter.ViewHolder<T>>(DiffCallback(getKey)) {
var onClickListener: ((T) -> Unit)? = null
@@ -50,14 +54,20 @@ abstract class DataBindingAdapter<T : Equatable>() :
open fun bind(holder: ViewHolder<T>, item: T) {
holder.binding.setVariable(BR.item, item)
holder.binding.executePendingBindings()
holder.binding.root.setOnClickListener {
val listener = onClickListener ?: return@setOnClickListener
listener(item)
if (onClickListener != null) {
holder.binding.root.setOnClickListener {
val listener = onClickListener ?: return@setOnClickListener
listener(item)
}
}
}
class DiffCallback<T : Equatable> : DiffUtil.ItemCallback<T>() {
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = oldItem === newItem
class DiffCallback<T : Equatable>(val getKey: ((T) -> Any)?) : DiffUtil.ItemCallback<T>() {
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = if (getKey != null) {
(getKey)(oldItem) == (getKey)(newItem)
} else {
oldItem === newItem
}
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = oldItem == newItem
}
@@ -90,10 +100,18 @@ class DetailAdapter : DataBindingAdapter<DetailAdapter.Detail>() {
val text: CharSequence,
val detailText: CharSequence? = null,
val links: Boolean = true,
val clickable: Boolean = false
val clickable: Boolean = false,
val hoursDays: OpeningHoursDays? = null
) : Equatable
override fun getItemViewType(position: Int): Int = R.layout.item_detail
override fun getItemViewType(position: Int): Int {
val item = getItem(position)
if (item.hoursDays != null) {
return R.layout.item_detail_openinghours
} else {
return R.layout.item_detail
}
}
}
fun buildDetails(loc: ChargeLocation?, ctx: Context): List<DetailAdapter.Detail> {
@@ -128,12 +146,12 @@ fun buildDetails(loc: ChargeLocation?, ctx: Context): List<DetailAdapter.Detail>
loc.faultReport.description ?: "",
clickable = true
) else null,
// TODO: separate layout for opening hours with expandable details
if (loc.openinghours != null && !loc.openinghours.isEmpty) DetailAdapter.Detail(
R.drawable.ic_hours,
R.string.hours,
loc.openinghours.getStatusText(ctx),
loc.openinghours.description
loc.openinghours.description,
hoursDays = loc.openinghours.days
) else null,
if (loc.cost != null) DetailAdapter.Detail(
R.drawable.ic_cost,
@@ -173,11 +191,18 @@ class FiltersAdapter : DataBindingAdapter<FilterWithValue<FilterValue>>() {
val itemids = mutableMapOf<String, Long>()
var maxId = 0L
override fun getItemViewType(position: Int): Int = when (getItem(position).filter) {
is BooleanFilter -> R.layout.item_filter_boolean
is MultipleChoiceFilter -> R.layout.item_filter_multiple_choice
is SliderFilter -> R.layout.item_filter_slider
}
override fun getItemViewType(position: Int): Int =
when (val filter = getItem(position).filter) {
is BooleanFilter -> R.layout.item_filter_boolean
is MultipleChoiceFilter -> {
if (filter.manyChoices) {
R.layout.item_filter_multiple_choice_large
} else {
R.layout.item_filter_multiple_choice
}
}
is SliderFilter -> R.layout.item_filter_slider
}
override fun bind(
holder: ViewHolder<FilterWithValue<FilterValue>>,
@@ -192,10 +217,18 @@ class FiltersAdapter : DataBindingAdapter<FilterWithValue<FilterValue>>() {
)
}
is MultipleChoiceFilterValue -> {
setupMultipleChoice(
holder.binding as ItemFilterMultipleChoiceBinding,
item.filter as MultipleChoiceFilter, item.value
)
val filter = item.filter as MultipleChoiceFilter
if (filter.manyChoices) {
setupMultipleChoiceMany(
holder.binding as ItemFilterMultipleChoiceLargeBinding,
filter, item.value
)
} else {
setupMultipleChoice(
holder.binding as ItemFilterMultipleChoiceBinding,
filter, item.value
)
}
}
}
}
@@ -283,6 +316,27 @@ class FiltersAdapter : DataBindingAdapter<FilterWithValue<FilterValue>>() {
updateButtons()
}
private fun setupMultipleChoiceMany(
binding: ItemFilterMultipleChoiceLargeBinding,
filter: MultipleChoiceFilter,
value: MultipleChoiceFilterValue
) {
if (value.all) {
value.values = filter.choices.keys.toMutableSet()
binding.notifyPropertyChanged(BR.item)
}
binding.btnEdit.setOnClickListener {
val dialog = MultiSelectDialog.getInstance(filter.name, filter.choices, value.values)
dialog.okListener = { selected ->
value.values = selected.toMutableSet()
value.all = value.values == filter.choices.keys
binding.item = binding.item
}
dialog.show((binding.root.context as AppCompatActivity).supportFragmentManager, null)
}
}
private fun setupSlider(
binding: ItemFilterSliderBinding,
filter: SliderFilter,

View File

@@ -3,6 +3,7 @@ package net.vonforst.evmap.api.goingelectric
import android.content.Context
import com.facebook.stetho.okhttp3.StethoInterceptor
import com.squareup.moshi.Moshi
import net.vonforst.evmap.BuildConfig
import okhttp3.Cache
import okhttp3.OkHttpClient
import retrofit2.Call
@@ -17,13 +18,16 @@ interface GoingElectricApi {
suspend fun getChargepoints(
@Query("sw_lat") swlat: Double, @Query("sw_lng") sw_lng: Double,
@Query("ne_lat") ne_lat: Double, @Query("ne_lng") ne_lng: Double,
@Query("clustering") clustering: Boolean,
@Query("zoom") zoom: Float,
@Query("cluster_distance") clusterDistance: Int?,
@Query("freecharging") freecharging: Boolean,
@Query("freeparking") freeparking: Boolean,
@Query("min_power") minPower: Int,
@Query("plugs") plugs: String?
@Query("clustering") clustering: Boolean = false,
@Query("cluster_distance") clusterDistance: Int? = null,
@Query("freecharging") freecharging: Boolean = false,
@Query("freeparking") freeparking: Boolean = false,
@Query("min_power") minPower: Int = 0,
@Query("plugs") plugs: String? = null,
@Query("chargecards") chargecards: String? = null,
@Query("networks") networks: String? = null,
@Query("startkey") startkey: Int? = null
): Response<ChargepointList>
@GET("chargepoints/")
@@ -36,7 +40,7 @@ interface GoingElectricApi {
suspend fun getNetworks(): Response<StringList>
@GET("chargepoints/chargecardlist/")
suspend fun getChargeCards(): Response<StringList>
suspend fun getChargeCards(): Response<ChargeCardList>
companion object {
private val cacheSize = 10L * 1024 * 1024; // 10MB
@@ -54,7 +58,9 @@ interface GoingElectricApi {
original = original.newBuilder().url(url).build()
chain.proceed(original)
}
addNetworkInterceptor(StethoInterceptor())
if (BuildConfig.DEBUG) {
addNetworkInterceptor(StethoInterceptor())
}
if (context != null) {
cache(Cache(context.getCacheDir(), cacheSize))
}

View File

@@ -15,6 +15,8 @@ import java.time.DayOfWeek
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.*
import kotlin.math.abs
import kotlin.math.floor
@@ -22,7 +24,8 @@ import kotlin.math.floor
@JsonClass(generateAdapter = true)
data class ChargepointList(
val status: String,
val chargelocations: List<ChargepointListItem>
val chargelocations: List<ChargepointListItem>,
@JsonObjectOrFalse val startkey: Int?
)
@JsonClass(generateAdapter = true)
@@ -31,6 +34,12 @@ data class StringList(
val result: List<String>
)
@JsonClass(generateAdapter = true)
data class ChargeCardList(
val status: String,
val result: List<ChargeCard>
)
sealed class ChargepointListItem
@JsonClass(generateAdapter = true)
@@ -158,9 +167,12 @@ data class OpeningHoursDays(
) {
fun getHoursForDate(date: LocalDate): Hours {
// TODO: check for holidays
return getHoursForDayOfWeek(date.dayOfWeek)
}
fun getHoursForDayOfWeek(dayOfWeek: DayOfWeek?): Hours {
@Suppress("WHEN_ENUM_CAN_BE_NULL_IN_JAVA")
return when (date.dayOfWeek) {
return when (dayOfWeek) {
DayOfWeek.MONDAY -> monday
DayOfWeek.TUESDAY -> tuesday
DayOfWeek.WEDNESDAY -> wednesday
@@ -168,6 +180,7 @@ data class OpeningHoursDays(
DayOfWeek.FRIDAY -> friday
DayOfWeek.SATURDAY -> saturday
DayOfWeek.SUNDAY -> sunday
null -> holiday
}
}
}
@@ -175,7 +188,16 @@ data class OpeningHoursDays(
data class Hours(
val start: LocalTime?,
val end: LocalTime?
)
) {
override fun toString(): String {
if (start != null && end != null) {
val fmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
return "${start.format(fmt)} - ${end.format(fmt)}"
} else {
return "closed"
}
}
}
@JsonClass(generateAdapter = true)
@Parcelize
@@ -248,4 +270,12 @@ data class Chargepoint(val type: String, val power: Double, val count: Int) : Eq
}
@JsonClass(generateAdapter = true)
data class FaultReport(val created: Instant?, val description: String?)
data class FaultReport(val created: Instant?, val description: String?)
@Entity
@JsonClass(generateAdapter = true)
data class ChargeCard(
@Json(name = "card_id") @PrimaryKey val id: Long,
val name: String,
val url: String
)

View File

@@ -18,7 +18,7 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationServices
import com.google.android.gms.maps.model.LatLng
import com.google.android.libraries.maps.model.LatLng
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.FavoritesAdapter

View File

@@ -10,7 +10,6 @@ import android.content.res.Configuration
import android.graphics.Color
import android.os.Bundle
import android.os.Handler
import android.transition.TransitionManager
import android.view.*
import android.view.inputmethod.InputMethodManager
import android.widget.TextView
@@ -32,16 +31,18 @@ import androidx.navigation.ui.setupWithNavController
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.transition.TransitionInflater
import androidx.transition.TransitionManager
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationServices
import com.google.android.gms.maps.CameraUpdateFactory
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.OnMapReadyCallback
import com.google.android.gms.maps.SupportMapFragment
import com.google.android.gms.maps.model.*
import com.google.android.libraries.maps.CameraUpdateFactory
import com.google.android.libraries.maps.GoogleMap
import com.google.android.libraries.maps.OnMapReadyCallback
import com.google.android.libraries.maps.SupportMapFragment
import com.google.android.libraries.maps.model.*
import com.google.android.libraries.places.api.model.Place
import com.google.android.libraries.places.widget.Autocomplete
import com.google.android.libraries.places.widget.model.AutocompleteActivityMode
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.transition.MaterialArcMotion
import com.google.android.material.transition.MaterialContainerTransform
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike
@@ -88,6 +89,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
private var markers: MutableBiMap<Marker, ChargeLocation> = HashBiMap()
private var clusterMarkers: List<Marker> = emptyList()
private var searchResultMarker: Marker? = null
private var connectionErrorSnackbar: Snackbar? = null
private lateinit var clusterIconGenerator: ClusterIconGenerator
private lateinit var chargerIconGenerator: ChargerIconGenerator
@@ -250,7 +252,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val materialTransform = MaterialContainerTransform().apply {
startView = binding.fabLayers
endView = binding.layersSheet
pathMotion = MaterialArcMotion()
setPathMotion(MaterialArcMotion())
duration = 250
scrimColor = Color.TRANSPARENT
}
@@ -263,7 +265,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val materialTransform = MaterialContainerTransform().apply {
startView = binding.layersSheet
endView = binding.fabLayers
pathMotion = MaterialArcMotion()
setPathMotion(MaterialArcMotion())
duration = 200
scrimColor = Color.TRANSPARENT
}
@@ -311,9 +313,31 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
unhighlightAllMarkers()
}
})
vm.chargepoints.observe(viewLifecycleOwner, Observer {
val chargepoints = it.data
if (chargepoints != null) updateMap(chargepoints)
vm.chargepoints.observe(viewLifecycleOwner, Observer { res ->
when (res.status) {
Status.ERROR -> {
val view = view ?: return@Observer
connectionErrorSnackbar?.dismiss()
connectionErrorSnackbar = Snackbar
.make(view, R.string.connection_error, Snackbar.LENGTH_INDEFINITE)
.setAction(R.string.retry) {
connectionErrorSnackbar?.dismiss()
vm.reloadChargepoints()
}
connectionErrorSnackbar!!.show()
}
Status.SUCCESS -> {
connectionErrorSnackbar?.dismiss()
}
Status.LOADING -> {
}
}
val chargepoints = res.data
if (chargepoints != null) {
updateMap(chargepoints)
}
})
vm.favorites.observe(viewLifecycleOwner, Observer {
updateFavoriteToggle()
@@ -346,11 +370,14 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
vm.mapTrafficEnabled.observe(viewLifecycleOwner, Observer {
map?.isTrafficEnabled = it
})
updateBackPressedCallback()
}
private fun updateBackPressedCallback() {
backPressedCallback.isEnabled =
vm.bottomSheetState.value != STATE_HIDDEN || vm.searchResult.value != null
vm.bottomSheetState.value != null && vm.bottomSheetState.value != STATE_HIDDEN
|| vm.searchResult.value != null
|| (vm.layersMenuOpen.value ?: false)
}
@@ -691,9 +718,21 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
}
filterView?.setOnLongClickListener {
requireView().findNavController().navigate(
R.id.action_map_to_filterFragment
// enable/disable filters
vm.filtersActive.value = !vm.filtersActive.value!!
// haptic feedback
filterView.performHapticFeedback(
HapticFeedbackConstants.LONG_PRESS,
HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING
)
// show snackbar
Snackbar.make(
requireView(), if (vm.filtersActive.value!!) {
R.string.filters_activated
} else {
R.string.filters_deactivated
}, Snackbar.LENGTH_SHORT
).show()
true
}
}

View File

@@ -0,0 +1,114 @@
package net.vonforst.evmap.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.core.widget.doAfterTextChanged
import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.android.synthetic.main.dialog_multi_select.*
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.DataBindingAdapter
import net.vonforst.evmap.adapter.Equatable
import java.util.*
import kotlin.collections.HashMap
import kotlin.collections.HashSet
class MultiSelectDialog : AppCompatDialogFragment() {
companion object {
fun getInstance(
title: String,
data: Map<String, String>,
selected: Set<String>
): MultiSelectDialog {
val dialog = MultiSelectDialog()
dialog.arguments = Bundle().apply {
putString("title", title)
putSerializable("data", HashMap(data))
putSerializable("selected", HashSet(selected))
}
return dialog
}
}
var okListener: ((Set<String>) -> Unit)? = null
var cancelListener: (() -> Unit)? = null
private lateinit var items: List<MultiSelectItem>
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.dialog_multi_select, container)
}
override fun onStart() {
super.onStart()
// dialog with 95% screen height
dialog?.window?.setLayout(
ViewGroup.LayoutParams.MATCH_PARENT,
(resources.displayMetrics.heightPixels * 0.95).toInt()
)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val data = requireArguments().getSerializable("data") as HashMap<String, String>
val selected = requireArguments().getSerializable("selected") as HashSet<String>
val title = requireArguments().getString("title")
dialogTitle.text = title
val adapter = Adapter()
list.adapter = adapter
list.layoutManager = LinearLayoutManager(view.context)
items = data.entries.toList().sortedBy { it.key }.map {
MultiSelectItem(it.key, it.value, it.key in selected)
}
adapter.submitList(items)
etSearch.doAfterTextChanged { text ->
adapter.submitList(search(items, text.toString()))
}
btnCancel.setOnClickListener {
cancelListener?.let { listener ->
listener()
}
dismiss()
}
btnOK.setOnClickListener {
okListener?.let { listener ->
val result = items.filter { it.selected }.map { it.key }.toSet()
listener(result)
}
dismiss()
}
btnAll.setOnClickListener {
items = items.map { MultiSelectItem(it.key, it.name, true) }
adapter.submitList(search(items, etSearch.text.toString()))
}
btnNone.setOnClickListener {
items = items.map { MultiSelectItem(it.key, it.name, false) }
adapter.submitList(search(items, etSearch.text.toString()))
}
}
private fun search(
items: List<MultiSelectItem>,
text: String
): List<MultiSelectItem> {
return items.filter { item ->
// search for string within name
text.toLowerCase(Locale.getDefault()) in item.name.toLowerCase(Locale.getDefault())
}
}
class Adapter() : DataBindingAdapter<MultiSelectItem>({ it.key }) {
override fun getItemViewType(position: Int) = R.layout.dialog_multi_select_item
}
}
data class MultiSelectItem(val key: String, val name: String, var selected: Boolean) : Equatable

View File

@@ -1,5 +1,6 @@
package net.vonforst.evmap.fragment
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.Toolbar
@@ -11,7 +12,8 @@ import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
class SettingsFragment : PreferenceFragmentCompat() {
class SettingsFragment : PreferenceFragmentCompat(),
SharedPreferences.OnSharedPreferenceChangeListener {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val toolbar = view.findViewById(R.id.toolbar) as Toolbar
@@ -33,4 +35,26 @@ class SettingsFragment : PreferenceFragmentCompat() {
}
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
when (key) {
"language" -> {
activity?.let {
it.finish();
it.startActivity(it.intent);
}
}
}
}
override fun onResume() {
super.onResume()
preferenceManager.sharedPreferences.registerOnSharedPreferenceChangeListener(this)
}
override fun onPause() {
preferenceManager.sharedPreferences
.unregisterOnSharedPreferenceChangeListener(this)
super.onPause()
}
}

View File

@@ -0,0 +1,47 @@
package net.vonforst.evmap.storage
import androidx.lifecycle.LiveData
import androidx.room.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.goingelectric.ChargeCard
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import java.time.Duration
import java.time.Instant
@Dao
interface ChargeCardDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(vararg chargeCards: ChargeCard)
@Delete
suspend fun delete(vararg chargeCards: ChargeCard)
@Query("SELECT * FROM chargeCard")
fun getAllChargeCards(): LiveData<List<ChargeCard>>
}
class ChargeCardRepository(
private val api: GoingElectricApi, private val scope: CoroutineScope,
private val dao: ChargeCardDao, private val prefs: PreferenceDataSource
) {
fun getChargeCards(): LiveData<List<ChargeCard>> {
scope.launch {
updateChargeCards()
}
return dao.getAllChargeCards()
}
private suspend fun updateChargeCards() {
if (Duration.between(prefs.lastChargeCardUpdate, Instant.now()) < Duration.ofDays(1)) return
val response = api.getChargeCards()
if (!response.isSuccessful) return
for (card in response.body()!!.result) {
dao.insert(card)
}
prefs.lastChargeCardUpdate = Instant.now()
}
}

View File

@@ -7,6 +7,7 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import net.vonforst.evmap.api.goingelectric.ChargeCard
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.viewmodel.BooleanFilterValue
import net.vonforst.evmap.viewmodel.MultipleChoiceFilterValue
@@ -18,20 +19,27 @@ import net.vonforst.evmap.viewmodel.SliderFilterValue
BooleanFilterValue::class,
MultipleChoiceFilterValue::class,
SliderFilterValue::class,
Plug::class
], version = 6
Plug::class,
Network::class,
ChargeCard::class
], version = 7
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun chargeLocationsDao(): ChargeLocationsDao
abstract fun filterValueDao(): FilterValueDao
abstract fun plugDao(): PlugDao
abstract fun networkDao(): NetworkDao
abstract fun chargeCardDao(): ChargeCardDao
companion object {
private lateinit var context: Context
private val database: AppDatabase by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
Room.databaseBuilder(context, AppDatabase::class.java, "evmap.db")
.addMigrations(MIGRATION_2, MIGRATION_3, MIGRATION_4, MIGRATION_5, MIGRATION_6)
.addMigrations(
MIGRATION_2, MIGRATION_3, MIGRATION_4, MIGRATION_5, MIGRATION_6,
MIGRATION_7
)
.build()
}
@@ -99,5 +107,12 @@ abstract class AppDatabase : RoomDatabase() {
}
}
}
private val MIGRATION_7 = object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS `Network` (`name` TEXT NOT NULL, PRIMARY KEY(`name`))")
db.execSQL("CREATE TABLE IF NOT EXISTS `ChargeCard` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, PRIMARY KEY(`id`))")
}
}
}
}

View File

@@ -0,0 +1,49 @@
package net.vonforst.evmap.storage
import androidx.lifecycle.LiveData
import androidx.room.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import java.time.Duration
import java.time.Instant
@Entity
data class Network(@PrimaryKey val name: String)
@Dao
interface NetworkDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(vararg networks: Network)
@Delete
suspend fun delete(vararg networks: Network)
@Query("SELECT * FROM network")
fun getAllNetworks(): LiveData<List<Network>>
}
class NetworkRepository(
private val api: GoingElectricApi, private val scope: CoroutineScope,
private val dao: NetworkDao, private val prefs: PreferenceDataSource
) {
fun getNetworks(): LiveData<List<Network>> {
scope.launch {
updateNetworks()
}
return dao.getAllNetworks()
}
private suspend fun updateNetworks() {
if (Duration.between(prefs.lastNetworkUpdate, Instant.now()) < Duration.ofDays(1)) return
val response = api.getNetworks()
if (!response.isSuccessful) return
for (name in response.body()!!.result) {
dao.insert(Network(name))
}
prefs.lastNetworkUpdate = Instant.now()
}
}

View File

@@ -18,4 +18,19 @@ class PreferenceDataSource(context: Context) {
set(value) {
sp.edit().putLong("last_plug_update", value.toEpochMilli()).apply()
}
var lastNetworkUpdate: Instant
get() = Instant.ofEpochMilli(sp.getLong("last_network_update", 0L))
set(value) {
sp.edit().putLong("last_network_update", value.toEpochMilli()).apply()
}
var lastChargeCardUpdate: Instant
get() = Instant.ofEpochMilli(sp.getLong("last_chargecard_update", 0L))
set(value) {
sp.edit().putLong("last_chargecard_update", value.toEpochMilli()).apply()
}
val language: String
get() = sp.getString("language", "default")!!
}

View File

@@ -2,10 +2,8 @@ package net.vonforst.evmap.ui
import android.content.Context
import android.content.res.ColorStateList
import android.os.Build
import android.text.Html
import android.text.Spanned
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat
@@ -19,6 +17,7 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.api.goingelectric.Chargepoint
import kotlin.math.roundToInt
@BindingAdapter("goneUnless")
@@ -122,6 +121,16 @@ fun setHtmlTextValue(textView: TextView, htmlText: String?) {
}
}
@BindingAdapter("android:layout_marginTop")
fun setTopMargin(view: View, topMargin: Float) {
val layoutParams = view.layoutParams as MarginLayoutParams
layoutParams.setMargins(
layoutParams.leftMargin, topMargin.roundToInt(),
layoutParams.rightMargin, layoutParams.bottomMargin
)
view.layoutParams = layoutParams
}
private fun availabilityColor(
status: List<ChargepointStatus>?,
context: Context

View File

@@ -0,0 +1,41 @@
package net.vonforst.evmap.ui;
import com.google.android.libraries.maps.model.LatLng
import com.google.maps.android.clustering.ClusterItem
import com.google.maps.android.clustering.algo.NonHierarchicalDistanceBasedAlgorithm
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.ChargeLocationCluster
import net.vonforst.evmap.api.goingelectric.ChargepointListItem
import net.vonforst.evmap.api.goingelectric.Coordinate
fun cluster(
result: List<ChargepointListItem>,
zoom: Float,
clusterDistance: Int
): List<ChargepointListItem> {
val clusters = result.filterIsInstance<ChargeLocationCluster>()
val locations = result.filterIsInstance<ChargeLocation>()
val clusterItems = locations.map { ChargepointClusterItem(it) }
val algo = NonHierarchicalDistanceBasedAlgorithm<ChargepointClusterItem>()
algo.maxDistanceBetweenClusteredItems = clusterDistance
algo.addItems(clusterItems)
return algo.getClusters(zoom).map {
if (it.size == 1) {
it.items.first().charger
} else {
ChargeLocationCluster(it.size, Coordinate(it.position.latitude, it.position.longitude))
}
} + clusters
}
private class ChargepointClusterItem(val charger: ChargeLocation) : ClusterItem {
override fun getSnippet(): String? = null
override fun getTitle(): String? = charger.name
override fun getPosition(): LatLng = LatLng(charger.coordinates.lat, charger.coordinates.lng)
}

View File

@@ -11,8 +11,8 @@ import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.widget.TextViewCompat
import com.google.android.gms.maps.model.BitmapDescriptor
import com.google.android.gms.maps.model.BitmapDescriptorFactory
import com.google.android.libraries.maps.model.BitmapDescriptor
import com.google.android.libraries.maps.model.BitmapDescriptorFactory
import com.google.maps.android.ui.IconGenerator
import com.google.maps.android.ui.SquareTextView
import net.vonforst.evmap.R

View File

@@ -5,7 +5,7 @@ import android.view.animation.BounceInterpolator
import androidx.core.animation.addListener
import androidx.interpolator.view.animation.FastOutLinearInInterpolator
import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
import com.google.android.gms.maps.model.Marker
import com.google.android.libraries.maps.model.Marker
import net.vonforst.evmap.R
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import kotlin.math.max

View File

@@ -0,0 +1,42 @@
package net.vonforst.evmap.utils
import android.annotation.TargetApi
import android.content.Context
import android.content.ContextWrapper
import android.content.res.Configuration
import android.os.Build
import java.util.*
class LocaleContextWrapper(base: Context?) : ContextWrapper(base) {
companion object {
fun wrap(context: Context, language: String): ContextWrapper {
val config: Configuration = context.resources.configuration
var sysLocale: Locale? = null
sysLocale = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
config.locales.get(0)
} else {
@Suppress("DEPRECATION")
config.locale
}
var ctx = context
if (language != "" && language != "default" && sysLocale.language != language) {
val locale = Locale(language)
Locale.setDefault(locale)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
config.setLocale(locale)
} else {
@Suppress("DEPRECATION")
config.locale = locale
}
ctx = context.createConfigurationContext(config)
}
return LocaleContextWrapper(ctx)
}
@TargetApi(Build.VERSION_CODES.N)
fun setSystemLocale(config: Configuration, locale: Locale?) {
config.setLocale(locale)
}
}
}

View File

@@ -2,7 +2,7 @@ package net.vonforst.evmap.viewmodel
import android.app.Application
import androidx.lifecycle.*
import com.google.android.gms.maps.model.LatLng
import com.google.android.libraries.maps.model.LatLng
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch

View File

@@ -10,12 +10,10 @@ import androidx.room.Entity
import androidx.room.PrimaryKey
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.api.goingelectric.ChargeCard
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.Plug
import net.vonforst.evmap.storage.PlugRepository
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.storage.*
import kotlin.math.abs
import kotlin.reflect.KClass
import kotlin.reflect.full.cast
@@ -28,7 +26,9 @@ internal fun mapPowerInverse(power: Int) = powerSteps
internal fun getFilters(
application: Application,
plugs: LiveData<List<Plug>>
plugs: LiveData<List<Plug>>,
networks: LiveData<List<Network>>,
chargeCards: LiveData<List<ChargeCard>>
): LiveData<List<Filter<FilterValue>>> {
return MediatorLiveData<List<Filter<FilterValue>>>().apply {
val plugNames = mapOf(
@@ -42,35 +42,57 @@ internal fun getFilters(
Chargepoint.CEE_BLAU to application.getString(R.string.plug_cee_blau),
Chargepoint.CEE_ROT to application.getString(R.string.plug_cee_rot)
)
addSource(plugs) { plugs ->
val plugMap = plugs.map { plug ->
plug.name to (plugNames[plug.name] ?: plug.name)
}.toMap()
value = listOf(
BooleanFilter(application.getString(R.string.filter_free), "freecharging"),
BooleanFilter(application.getString(R.string.filter_free_parking), "freeparking"),
SliderFilter(
application.getString(R.string.filter_min_power), "min_power",
powerSteps.size - 1,
mapping = ::mapPower,
inverseMapping = ::mapPowerInverse,
unit = "kW"
),
MultipleChoiceFilter(
application.getString(R.string.filter_connectors), "connectors",
plugMap,
commonChoices = setOf(Chargepoint.TYPE_2, Chargepoint.CCS, Chargepoint.CHADEMO)
),
SliderFilter(
application.getString(R.string.filter_min_connectors),
"min_connectors",
10
)
)
listOf(plugs, networks, chargeCards).forEach { source ->
addSource(source) { _ ->
buildFilters(plugs, plugNames, networks, chargeCards, application)
}
}
}
}
private fun MediatorLiveData<List<Filter<FilterValue>>>.buildFilters(
plugs: LiveData<List<Plug>>,
plugNames: Map<String, String>,
networks: LiveData<List<Network>>,
chargeCards: LiveData<List<ChargeCard>>,
application: Application
) {
val plugMap = plugs.value?.map { plug ->
plug.name to (plugNames[plug.name] ?: plug.name)
}?.toMap() ?: return
val networkMap = networks.value?.map { it.name to it.name }?.toMap() ?: return
val chargecardMap = chargeCards.value?.map { it.id.toString() to it.name }?.toMap() ?: return
value = listOf(
BooleanFilter(application.getString(R.string.filter_free), "freecharging"),
BooleanFilter(application.getString(R.string.filter_free_parking), "freeparking"),
SliderFilter(
application.getString(R.string.filter_min_power), "min_power",
powerSteps.size - 1,
mapping = ::mapPower,
inverseMapping = ::mapPowerInverse,
unit = "kW"
),
MultipleChoiceFilter(
application.getString(R.string.filter_connectors), "connectors",
plugMap,
commonChoices = setOf(Chargepoint.TYPE_2, Chargepoint.CCS, Chargepoint.CHADEMO)
),
SliderFilter(
application.getString(R.string.filter_min_connectors),
"min_connectors",
10
),
MultipleChoiceFilter(
application.getString(R.string.filter_networks), "networks",
networkMap, manyChoices = true
),
MultipleChoiceFilter(
application.getString(R.string.filter_chargecards), "chargecards",
chargecardMap, manyChoices = true
)
)
}
internal fun filtersWithValue(
filters: LiveData<List<Filter<FilterValue>>>,
@@ -107,9 +129,14 @@ class FilterViewModel(application: Application, geApiKey: String) :
private val plugs: LiveData<List<Plug>> by lazy {
PlugRepository(api, viewModelScope, db.plugDao(), prefs).getPlugs()
}
private val networks: LiveData<List<Network>> by lazy {
NetworkRepository(api, viewModelScope, db.networkDao(), prefs).getNetworks()
}
private val chargeCards: LiveData<List<ChargeCard>> by lazy {
ChargeCardRepository(api, viewModelScope, db.chargeCardDao(), prefs).getChargeCards()
}
private val filters: LiveData<List<Filter<FilterValue>>> by lazy {
getFilters(application, plugs)
getFilters(application, plugs, networks, chargeCards)
}
private val filterValues: LiveData<List<FilterValue>> by lazy {
@@ -144,7 +171,8 @@ data class MultipleChoiceFilter(
override val name: String,
override val key: String,
val choices: Map<String, String>,
val commonChoices: Set<String>? = null
val commonChoices: Set<String>? = null,
val manyChoices: Boolean = false
) : Filter<MultipleChoiceFilterValue>() {
override val valueClass: KClass<MultipleChoiceFilterValue> = MultipleChoiceFilterValue::class
override fun defaultValue() = MultipleChoiceFilterValue(key, mutableSetOf(), true)

View File

@@ -2,24 +2,21 @@ package net.vonforst.evmap.viewmodel
import android.app.Application
import androidx.lifecycle.*
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.model.LatLngBounds
import com.google.android.libraries.maps.GoogleMap
import com.google.android.libraries.maps.model.LatLngBounds
import com.google.android.libraries.places.api.model.Place
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.ChargepointList
import net.vonforst.evmap.api.goingelectric.ChargepointListItem
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.Plug
import net.vonforst.evmap.storage.PlugRepository
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.api.goingelectric.*
import net.vonforst.evmap.storage.*
import net.vonforst.evmap.ui.cluster
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.io.IOException
data class MapPosition(val bounds: LatLngBounds, val zoom: Float)
@@ -52,7 +49,13 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
private val plugs: LiveData<List<Plug>> by lazy {
PlugRepository(api, viewModelScope, db.plugDao(), prefs).getPlugs()
}
private val filters = getFilters(application, plugs)
private val networks: LiveData<List<Network>> by lazy {
NetworkRepository(api, viewModelScope, db.networkDao(), prefs).getNetworks()
}
private val chargeCards: LiveData<List<ChargeCard>> by lazy {
ChargeCardRepository(api, viewModelScope, db.chargeCardDao(), prefs).getChargeCards()
}
private val filters = getFilters(application, plugs, networks, chargeCards)
private val filtersWithValue: LiveData<List<FilterWithValue<out FilterValue>>> by lazy {
filtersWithValue(filters, filterValues, filtersActive)
@@ -74,9 +77,7 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
value = Resource.loading(emptyList())
listOf(mapPosition, filtersWithValue).forEach {
addSource(it) {
val pos = mapPosition.value ?: return@addSource
val filters = filtersWithValue.value ?: return@addSource
loadChargepoints(pos, filters)
reloadChargepoints()
}
}
}
@@ -172,6 +173,12 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
}
}
fun reloadChargepoints() {
val pos = mapPosition.value ?: return
val filters = filtersWithValue.value ?: return
loadChargepoints(pos, filters)
}
private fun loadChargepoints(
mapPosition: MapPosition,
filters: List<FilterWithValue<out FilterValue>>
@@ -191,54 +198,110 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
zoom: Float,
filters: List<FilterWithValue<out FilterValue>>
): Resource<List<ChargepointListItem>> {
val freecharging =
(filters.find { it.value.key == "freecharging" }!!.value as BooleanFilterValue).value
val freeparking =
(filters.find { it.value.key == "freeparking" }!!.value as BooleanFilterValue).value
val minPower =
(filters.find { it.value.key == "min_power" }!!.value as SliderFilterValue).value
val minConnectors =
(filters.find { it.value.key == "min_connectors" }!!.value as SliderFilterValue).value
val freecharging = getBooleanValue(filters, "freecharging")
val freeparking = getBooleanValue(filters, "freeparking")
val minPower = getSliderValue(filters, "min_power")
val minConnectors = getSliderValue(filters, "min_connectors")
val connectorsVal =
filters.find { it.value.key == "connectors" }!!.value as MultipleChoiceFilterValue
val connectors = if (connectorsVal.all) null else connectorsVal.values.joinToString(",")
val connectorsVal = getMultipleChoiceValue(filters, "connectors")
if (connectorsVal.values.isEmpty() && !connectorsVal.all) {
// no connectors chosen
return Resource.success(emptyList())
}
val connectors = formatMultipleChoice(connectorsVal)
val chargeCardsVal = getMultipleChoiceValue(filters, "chargecards")
if (chargeCardsVal.values.isEmpty() && !chargeCardsVal.all) {
// no chargeCards chosen
return Resource.success(emptyList())
}
val chargeCards = formatMultipleChoice(chargeCardsVal)
val networksVal = getMultipleChoiceValue(filters, "networks")
if (networksVal.values.isEmpty() && !networksVal.all) {
// no networks chosen
return Resource.success(emptyList())
}
val networks = formatMultipleChoice(networksVal)
// do not use clustering if filters need to be applied locally.
val useClustering = minConnectors <= 1 && zoom < 13
val useClustering = zoom < 13
val geClusteringAvailable = minConnectors <= 1
val useGeClustering = useClustering && geClusteringAvailable
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
val response = api.getChargepoints(
bounds.southwest.latitude, bounds.southwest.longitude,
bounds.northeast.latitude, bounds.northeast.longitude,
clustering = useClustering, zoom = zoom,
clusterDistance = clusterDistance, freecharging = freecharging, minPower = minPower,
freeparking = freeparking, plugs = connectors
)
if (!response.isSuccessful || response.body()!!.status != "ok") {
return Resource.error(response.message(), chargepoints.value?.data)
} else {
val data = response.body()!!.chargelocations.filter { it ->
// apply filters which GoingElectric does not support natively
if (it is ChargeLocation) {
it.chargepoints
.filter { it.power >= minPower }
.filter { if (!connectorsVal.all) it.type in connectorsVal.values else true }
.sumBy { it.count } >= minConnectors
var startkey: Int? = null
val data = mutableListOf<ChargepointListItem>()
do {
// load all pages of the response
try {
val response = api.getChargepoints(
bounds.southwest.latitude,
bounds.southwest.longitude,
bounds.northeast.latitude,
bounds.northeast.longitude,
clustering = useGeClustering,
zoom = zoom,
clusterDistance = clusterDistance,
freecharging = freecharging,
minPower = minPower,
freeparking = freeparking,
plugs = connectors,
chargecards = chargeCards,
networks = networks,
startkey = startkey
)
if (!response.isSuccessful || response.body()!!.status != "ok") {
return Resource.error(response.message(), chargepoints.value?.data)
} else {
true
val body = response.body()!!
data.addAll(body.chargelocations)
startkey = body.startkey
}
} catch (e: IOException) {
return Resource.error(e.message, chargepoints.value?.data)
}
} while (startkey != null && startkey < 10000)
return Resource.success(data)
var result = data.filter { it ->
// apply filters which GoingElectric does not support natively
if (it is ChargeLocation) {
it.chargepoints
.filter { it.power >= minPower }
.filter { if (!connectorsVal.all) it.type in connectorsVal.values else true }
.sumBy { it.count } >= minConnectors
} else {
true
}
}
if (!geClusteringAvailable && useClustering) {
// apply local clustering if server side clustering is not available
Dispatchers.IO.run {
result = cluster(result, zoom, clusterDistance!!)
}
}
return Resource.success(result)
}
private fun formatMultipleChoice(connectorsVal: MultipleChoiceFilterValue) =
if (connectorsVal.all) null else connectorsVal.values.joinToString(",")
private fun getBooleanValue(
filters: List<FilterWithValue<out FilterValue>>,
key: String
) = (filters.find { it.value.key == key }!!.value as BooleanFilterValue).value
private fun getSliderValue(
filters: List<FilterWithValue<out FilterValue>>,
key: String
) = (filters.find { it.value.key == key }!!.value as SliderFilterValue).value
private fun getMultipleChoiceValue(
filters: List<FilterWithValue<out FilterValue>>,
key: String
) = filters.find { it.value.key == key }!!.value as MultipleChoiceFilterValue
private suspend fun loadAvailability(charger: ChargeLocation) {
availability.value = Resource.loading(null)
availability.value = getAvailability(charger)

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?selectableItemBackgroundBorderless"/>
<item android:drawable="@drawable/expand_toggle_icon"
android:top="4dp"
android:left="4dp"
android:right="4dp"
android:bottom="4dp"/>
</layer-list>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="false" android:drawable="@drawable/ic_expand" />
<item android:state_checked="true" android:drawable="@drawable/ic_collapse" />
</selector>

View 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,8l-6,6 1.41,1.41L12,10.83l4.59,4.58L18,14z" />
</vector>

View 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="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z" />
</vector>

View 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="M16.59,8.59L12,13.17 7.41,8.59 6,10l6,6 6,-6z" />
</vector>

View 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="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z" />
</vector>

View File

@@ -30,7 +30,9 @@
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="8dp"
app:cardCornerRadius="@dimen/detail_corner_radius"
android:layout_marginBottom="@dimen/detail_corner_radius_negative"
android:paddingBottom="@dimen/detail_corner_radius"
app:cardElevation="6dp">
<androidx.constraintlayout.widget.ConstraintLayout

View File

@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/dialogTitle"
style="@style/MaterialAlertDialog.MaterialComponents.Title.Text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="18dp"
android:layout_marginEnd="8dp"
app:layout_constraintEnd_toStartOf="@+id/btnAll"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Select Something" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="8dp"
app:layout_constraintBottom_toTopOf="@+id/btnOK"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tilSearch" />
<Button
android:id="@+id/btnAll"
style="@style/Widget.MaterialComponents.Button.TextButton.Dialog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/all"
app:layout_constraintBaseline_toBaselineOf="@+id/dialogTitle"
app:layout_constraintEnd_toStartOf="@+id/btnNone" />
<Button
android:id="@+id/btnOK"
style="@style/Widget.MaterialComponents.Button.TextButton.Dialog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:text="@string/ok"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<Button
android:id="@+id/btnCancel"
style="@style/Widget.MaterialComponents.Button.TextButton.Dialog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="@string/cancel"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/btnOK" />
<Button
android:id="@+id/btnNone"
style="@style/Widget.MaterialComponents.Button.TextButton.Dialog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="@string/none"
app:layout_constraintBaseline_toBaselineOf="@+id/btnAll"
app:layout_constraintEnd_toEndOf="parent" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilSearch"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:hint="@string/search"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/btnAll"
app:startIconDrawable="@drawable/ic_search">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etSearch"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="net.vonforst.evmap.fragment.MultiSelectItem" />
<variable
name="item"
type="MultiSelectItem" />
</data>
<CheckBox
android:id="@android:id/text1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?attr/listPreferredItemHeightSmall"
android:layout_marginStart="?attr/dialogPreferredPadding"
android:layout_marginEnd="?attr/dialogPreferredPadding"
android:paddingStart="20dp"
android:ellipsize="marquee"
android:text="@{item.name}"
android:checked="@={item.selected}"
tools:text="Item"
tools:ignore="RtlSymmetry" />
</layout>

View File

@@ -21,7 +21,7 @@
<fragment
android:id="@+id/map"
android:name="com.google.android.gms.maps.SupportMapFragment"
android:name="com.google.android.libraries.maps.SupportMapFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MapsActivity" />

View File

@@ -0,0 +1,204 @@
<?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="java.time.DayOfWeek" />
<import type="android.transition.TransitionManager" />
<variable
name="item"
type="net.vonforst.evmap.adapter.DetailAdapter.Detail" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="@{item.clickable}"
app:selectableItemBackground="@{item.clickable}">
<TextView
android:id="@+id/textView9"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="18dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="14dp"
android:maxLines="1"
android:text="@{item.text}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/expandToggle"
app:layout_constraintStart_toEndOf="@+id/imageView3"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0"
tools:text="Lorem ipsum" />
<ImageView
android:id="@+id/imageView3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:contentDescription="@{item.contentDescription}"
android:tint="?colorPrimary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@{item.icon}"
tools:srcCompat="@drawable/ic_address" />
<TextView
android:id="@+id/textView8"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="16dp"
android:autoLink="@{item.links ? Linkify.WEB_URLS | Linkify.PHONE_NUMBERS : 0}"
android:linksClickable="@{item.links}"
android:text="@{item.detailText}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:goneUnless="@{item.detailText != null}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/expandToggle"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintTop_toBottomOf="@+id/textView9"
app:layout_constraintVertical_bias="0.0"
tools:text="Lorem ipsum" />
<include
android:id="@+id/hours_mon"
layout="@layout/item_detail_openinghours_item"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
app:dayOfWeek="@{DayOfWeek.MONDAY}"
app:goneUnless="@{expandToggle.checked}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintTop_toBottomOf="@+id/textView8" />
<include
android:id="@+id/hours_tue"
layout="@layout/item_detail_openinghours_item"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginEnd="16dp"
app:goneUnless="@{expandToggle.checked}"
app:dayOfWeek="@{DayOfWeek.TUESDAY}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintTop_toBottomOf="@id/hours_mon" />
<include
android:id="@+id/hours_wed"
layout="@layout/item_detail_openinghours_item"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginEnd="16dp"
app:goneUnless="@{expandToggle.checked}"
app:dayOfWeek="@{DayOfWeek.WEDNESDAY}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintTop_toBottomOf="@id/hours_tue" />
<include
android:id="@+id/hours_thu"
layout="@layout/item_detail_openinghours_item"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginEnd="16dp"
app:goneUnless="@{expandToggle.checked}"
app:dayOfWeek="@{DayOfWeek.THURSDAY}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintTop_toBottomOf="@id/hours_wed" />
<include
android:id="@+id/hours_fri"
layout="@layout/item_detail_openinghours_item"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginEnd="16dp"
app:goneUnless="@{expandToggle.checked}"
app:dayOfWeek="@{DayOfWeek.FRIDAY}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintTop_toBottomOf="@id/hours_thu" />
<include
android:id="@+id/hours_sat"
layout="@layout/item_detail_openinghours_item"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginEnd="16dp"
app:goneUnless="@{expandToggle.checked}"
app:dayOfWeek="@{DayOfWeek.SATURDAY}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintTop_toBottomOf="@id/hours_fri" />
<include
android:id="@+id/hours_sun"
layout="@layout/item_detail_openinghours_item"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginEnd="16dp"
app:goneUnless="@{expandToggle.checked}"
app:dayOfWeek="@{DayOfWeek.SUNDAY}"
app:hours="@{item.hoursDays}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintTop_toBottomOf="@id/hours_sat" />
<include
android:id="@+id/hours_holiday"
layout="@layout/item_detail_openinghours_item"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
app:goneUnless="@{expandToggle.checked}"
app:dayOfWeek="@{null}"
app:hours="@{item.hoursDays}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textView9"
app:layout_constraintTop_toBottomOf="@id/hours_sun" />
<ToggleButton
android:id="@+id/expandToggle"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginTop="@{item.detailText != null ? @dimen/expand_toggle_padding_large : @dimen/expand_toggle_padding_small}"
android:layout_marginEnd="16dp"
android:background="@drawable/expand_toggle"
android:onClick="@{() -> TransitionManager.beginDelayedTransition(container)}"
android:textOff=""
android:textOn=""
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<import type="java.time.format.TextStyle" />
<import type="java.util.Locale" />
<variable
name="hours"
type="net.vonforst.evmap.api.goingelectric.OpeningHoursDays" />
<variable
name="dayOfWeek"
type="java.time.DayOfWeek" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/textView24"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{dayOfWeek != null ? dayOfWeek.getDisplayName(TextStyle.FULL, context.resources.configuration.locale) : @string/holiday}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Montag" />
<TextView
android:id="@+id/textView25"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="100dp"
android:text="@{hours.getHoursForDayOfWeek(dayOfWeek).toString().equals(&quot;closed&quot;) ? @string/closed_unfmt : hours.getHoursForDayOfWeek(dayOfWeek).toString()}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="07:00-21:00" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@@ -0,0 +1,70 @@
<?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="net.vonforst.evmap.viewmodel.MultipleChoiceFilter" />
<import type="net.vonforst.evmap.viewmodel.MultipleChoiceFilterValue" />
<import type="net.vonforst.evmap.viewmodel.FilterWithValue" />
<variable
name="item"
type="FilterWithValue&lt;MultipleChoiceFilterValue&gt;" />
<variable
name="showingAll"
type="boolean" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/textView17"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="8dp"
android:text="@{item.filter.name}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
app:layout_constraintEnd_toStartOf="@+id/btnEdit"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Networks" />
<ImageButton
android:id="@+id/btnEdit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:background="?selectableItemBackgroundBorderless"
android:padding="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_edit"
android:contentDescription="@string/edit" />
<TextView
android:id="@+id/textView26"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="8dp"
android:text="@{item.value.all ? @string/all_selected : @string/number_selected(item.value.values.size())}"
app:layout_constraintEnd_toStartOf="@+id/btnEdit"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView17"
tools:text="4 selected" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@@ -6,7 +6,7 @@
<import type="net.vonforst.evmap.viewmodel.MapViewModel" />
<import type="com.google.android.gms.maps.GoogleMap" />
<import type="com.google.android.libraries.maps.GoogleMap" />
<variable
name="vm"

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="pref_language_names">
<item>Gerätesprache verwenden</item>
<item>Englisch</item>
<item>Deutsch</item>
</string-array>
</resources>

View File

@@ -10,6 +10,8 @@
<string name="hours">Öffnungszeiten</string>
<string name="open_247"><![CDATA[<b>24 Stunden geöffnet</b>]]></string>
<string name="closed"><![CDATA[<b>Geschlossen</b>]]></string>
<string name="closed_unfmt">Geschlossen</string>
<string name="holiday">Feiertag</string>
<string name="open_closesat"><![CDATA[<b>Geöffnet</b> · Schließt um %s]]></string>
<string name="closed_opensat"><![CDATA[<b>Geschlossen</b> · Öffnet um %s]]></string>
<string name="cost">Kosten</string>
@@ -75,8 +77,21 @@
<string name="map_traffic">Verkehr</string>
<string name="faq">FAQ</string>
<string name="menu_filters_active">Filter aktiv</string>
<string name="filters_activated">Filter aktiviert</string>
<string name="filters_deactivated">Filter deaktiviert</string>
<string name="menu_edit_filters">Filter bearbeiten…</string>
<string name="go_to_chargeprice"><![CDATA[Preisvergleich<br/><small>mit Chargeprice.app</small>]]></string>
<string name="go_to_chargeprice">Preisvergleich</string>
<string name="fault_report">Störungsmeldung</string>
<string name="fault_report_date">Störungsmeldung (Letztes Update: %s)</string>
<string name="filter_networks">Verbünde</string>
<string name="filter_chargecards">Ladetarife</string>
<string name="all_selected">Alle ausgewählt</string>
<string name="number_selected">%d ausgewählt</string>
<string name="edit">bearbeiten</string>
<string name="cancel">Abbrechen</string>
<string name="ok">OK</string>
<string name="pref_language">Sprache</string>
<string name="pref_language_summary">App-Sprache ändern</string>
<string name="connection_error">Ladesäulen konnten nicht geladen werden</string>
<string name="retry">Wiederholen</string>
</resources>

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="AppTheme.Base">
<item name="android:navigationBarColor">@android:color/white</item>
</style>
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="pref_language_names">
<item>Device default</item>
<item>English</item>
<item>German</item>
</string-array>
<string-array name="pref_language_values" tranlatable="false">
<item>default</item>
<item>en</item>
<item>de</item>
</string-array>
</resources>

View File

@@ -3,4 +3,8 @@
<dimen name="peek_height">72dp</dimen>
<dimen name="gallery_height">200dp</dimen>
<dimen name="gallery_height_with_margin">208dp</dimen>
<dimen name="detail_corner_radius">8dp</dimen>
<dimen name="detail_corner_radius_negative">-8dp</dimen>
<dimen name="expand_toggle_padding_large">16dp</dimen>
<dimen name="expand_toggle_padding_small">8dp</dimen>
</resources>

View File

@@ -11,6 +11,8 @@
<string name="closed"><![CDATA[<b>Closed</b>]]></string>
<string name="open_closesat"><![CDATA[<b>Open</b> · Closes at %s]]></string>
<string name="closed_opensat"><![CDATA[<b>Closed</b> · Opens at %s]]></string>
<string name="closed_unfmt">Closed</string>
<string name="holiday">Holiday</string>
<string name="cost">Cost</string>
<string name="cost_detail"><![CDATA[<b>Charging:</b> %s · <b>Parking:</b> %s]]></string>
<string name="free">Free</string>
@@ -74,8 +76,21 @@
<string name="map_traffic">Traffic</string>
<string name="faq">FAQ</string>
<string name="menu_filters_active">Filters active</string>
<string name="filters_activated">Filters activated</string>
<string name="filters_deactivated">Filters deactivated</string>
<string name="menu_edit_filters">Edit filters…</string>
<string name="go_to_chargeprice">Compare prices</string>
<string name="fault_report">Fault report</string>
<string name="fault_report_date">Fault report (last update: %s)</string>
<string name="filter_networks">Networks</string>
<string name="filter_chargecards">Payment methods</string>
<string name="all_selected">All selected</string>
<string name="number_selected">%d selected</string>
<string name="edit">edit</string>
<string name="cancel">Cancel</string>
<string name="ok">OK</string>
<string name="pref_language">Language</string>
<string name="pref_language_summary">Change the app language</string>
<string name="connection_error">Could not load charging stations</string>
<string name="retry">Retry</string>
</resources>

View File

@@ -10,6 +10,13 @@
android:summaryOff="@string/pref_navigate_use_maps_off"
android:defaultValue="true" />
<ListPreference
android:key="language"
android:title="@string/pref_language"
android:entries="@array/pref_language_names"
android:entryValues="@array/pref_language_values"
android:defaultValue="default"
android:summary="@string/pref_language_summary" />
</PreferenceCategory>
</PreferenceScreen>

View File

@@ -0,0 +1,16 @@
package net.vonforst.evmap
import net.vonforst.evmap.api.goingelectric.GoingElectricApiTest
import okhttp3.mockwebserver.MockResponse
import java.net.HttpURLConnection
val notFoundResponse: MockResponse =
MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_FOUND)
fun okResponse(file: String): MockResponse {
val body = readResource(file) ?: return notFoundResponse
return MockResponse().setResponseCode(HttpURLConnection.HTTP_OK).setBody(body)
}
private fun readResource(s: String) =
GoingElectricApiTest::class.java.getResource(s)?.readText()

View File

@@ -4,6 +4,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import net.vonforst.evmap.okResponse
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
@@ -37,9 +38,7 @@ class NewMotionAvailabilityDetectorTest {
when (urlHead) {
"ge/chargepoints" -> {
val id = request.requestUrl.queryParameter("ge_id")
val body = readResource("/chargers/$id.json") ?: return notFoundResponse
return MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(body)
return okResponse("/chargers/$id.json")
}
"nm/markers" -> {
val urlTail = segments.subList(2, segments.size).joinToString("/")
@@ -48,16 +47,11 @@ class NewMotionAvailabilityDetectorTest {
"9.444284/9.644283999999999/54.376699/54.576699000000005" -> 18284
else -> -1
}
val body =
readResource("/newmotion/$id/markers.json") ?: return notFoundResponse
return MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(body)
return okResponse("/newmotion/$id/markers.json")
}
"nm/locations" -> {
val id = segments.last()
val body = readResource("/newmotion/$id.json") ?: return notFoundResponse
return MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(body)
return okResponse("/newmotion/$id.json")
}
else -> return notFoundResponse
}

View File

@@ -0,0 +1,105 @@
package net.vonforst.evmap.api.goingelectric
import kotlinx.coroutines.runBlocking
import net.vonforst.evmap.notFoundResponse
import net.vonforst.evmap.okResponse
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class GoingElectricApiTest {
val api: GoingElectricApi
val webServer = MockWebServer()
init {
webServer.start()
val apikey = ""
val baseurl = webServer.url("/ge/").toString()
api = GoingElectricApi.create(apikey, baseurl)
webServer.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val segments = request.requestUrl.pathSegments()
val urlHead = segments.subList(0, 2).joinToString("/")
when (urlHead) {
"ge/chargepoints" -> {
val id = request.requestUrl.queryParameter("ge_id")
if (id != null) {
return okResponse("/chargers/$id.json")
} else {
val freeparking =
request.requestUrl.queryParameter("freeparking")!!.toBoolean()
val freecharging =
request.requestUrl.queryParameter("freecharging")!!.toBoolean()
return if (freeparking && freecharging) {
okResponse("/chargers/list-empty.json")
} else if (freecharging) {
okResponse("/chargers/list.json")
} else {
okResponse("/chargers/list-startkey.json")
}
}
}
else -> return notFoundResponse
}
}
}
}
@Test
fun testLoadChargepointDetail() {
val response = api.getChargepointDetail(2105).execute()
assertTrue(response.isSuccessful)
val body = response.body()!!
assertEquals("ok", body.status)
assertEquals(null, body.startkey)
assertEquals(1, body.chargelocations.size)
val charger = body.chargelocations[0] as ChargeLocation
assertEquals(2105, charger.id)
}
@Test
fun testLoadChargepointList() {
val response = runBlocking {
api.getChargepoints(1.0, 1.0, 2.0, 2.0, 11f, freecharging = true)
}
assertTrue(response.isSuccessful)
val body = response.body()!!
assertEquals("ok", body.status)
assertEquals(null, body.startkey)
assertEquals(2, body.chargelocations.size)
val charger = body.chargelocations[0] as ChargeLocation
assertEquals(41161, charger.id)
}
@Test
fun testLoadChargepointListEmpty() {
val response = runBlocking {
api.getChargepoints(1.0, 1.0, 2.0, 2.0, 11f, freeparking = true, freecharging = true)
}
assertTrue(response.isSuccessful)
val body = response.body()!!
assertEquals("ok", body.status)
assertEquals(null, body.startkey)
assertEquals(0, body.chargelocations.size)
}
@Test
fun testLoadChargepointListStartkey() {
val response = runBlocking {
api.getChargepoints(1.0, 1.0, 2.0, 2.0, 1f)
}
assertTrue(response.isSuccessful)
val body = response.body()!!
assertEquals("ok", body.status)
assertEquals(2, body.startkey)
assertEquals(2, body.chargelocations.size)
val charger = body.chargelocations[0] as ChargeLocation
assertEquals(41161, charger.id)
}
}

View File

@@ -0,0 +1,5 @@
{
"status": "ok",
"startkey": false,
"chargelocations": []
}

View File

@@ -0,0 +1,61 @@
{
"status": "ok",
"chargelocations": [
{
"chargepoints": [
{
"type": "Typ2",
"power": 22,
"count": 2
}
],
"ge_id": 41161,
"name": "BMW Autohaus B&K",
"address": {
"city": "Hamburg",
"country": "Deutschland",
"postcode": "21073",
"street": "Buxtehuder Straße 112"
},
"coordinates": {
"lat": 53.469542,
"lng": 9.964063
},
"network": "Digital Energy Solutions",
"url": "//www.goingelectric.de/stromtankstellen/Deutschland/Hamburg/BMW-Autohaus-B-K-Buxtehuder-Strasse-112/41161/",
"fault_report": false,
"verified": false
},
{
"chargepoints": [
{
"type": "Typ2",
"power": 22,
"count": 2
},
{
"type": "Schuko",
"power": 3.6,
"count": 2
}
],
"ge_id": 41226,
"name": "Saseler Chaussee",
"address": {
"city": "Hamburg",
"country": "Deutschland",
"postcode": "22393",
"street": "Saseler Chaussee 94a"
},
"coordinates": {
"lat": 53.644021,
"lng": 10.099783
},
"network": "Stromnetz Hamburg",
"url": "//www.goingelectric.de/stromtankstellen/Deutschland/Hamburg/Saseler-Chaussee-Saseler-Chaussee-94a/41226/",
"fault_report": false,
"verified": false
}
],
"startkey": 2
}

View File

@@ -0,0 +1,60 @@
{
"status": "ok",
"chargelocations": [
{
"chargepoints": [
{
"type": "Typ2",
"power": 22,
"count": 2
}
],
"ge_id": 41161,
"name": "BMW Autohaus B&K",
"address": {
"city": "Hamburg",
"country": "Deutschland",
"postcode": "21073",
"street": "Buxtehuder Straße 112"
},
"coordinates": {
"lat": 53.469542,
"lng": 9.964063
},
"network": "Digital Energy Solutions",
"url": "//www.goingelectric.de/stromtankstellen/Deutschland/Hamburg/BMW-Autohaus-B-K-Buxtehuder-Strasse-112/41161/",
"fault_report": false,
"verified": false
},
{
"chargepoints": [
{
"type": "Typ2",
"power": 22,
"count": 2
},
{
"type": "Schuko",
"power": 3.6,
"count": 2
}
],
"ge_id": 41226,
"name": "Saseler Chaussee",
"address": {
"city": "Hamburg",
"country": "Deutschland",
"postcode": "22393",
"street": "Saseler Chaussee 94a"
},
"coordinates": {
"lat": 53.644021,
"lng": 10.099783
},
"network": "Stromnetz Hamburg",
"url": "//www.goingelectric.de/stromtankstellen/Deutschland/Hamburg/Saseler-Chaussee-Saseler-Chaussee-94a/41226/",
"fault_report": false,
"verified": false
}
]
}

View File

@@ -1,14 +1,14 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.3.71'
ext.kotlin_version = '1.3.72'
ext.about_libs_version = '8.1.1'
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.0.0-rc01'
classpath 'com.android.tools.build:gradle:4.0.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:$about_libs_version"
@@ -25,6 +25,9 @@ allprojects {
google()
jcenter()
maven { url 'https://jitpack.io' }
flatDir {
dirs 'libs'
}
}
}