Encapsulate of GoingElectric API to create an interface the OpenChargeMap API can implement

This commit is contained in:
johan12345
2021-06-03 22:02:19 +02:00
parent 34c83c2253
commit 9b94bbf098
46 changed files with 1119 additions and 927 deletions

View File

@@ -34,9 +34,10 @@ import net.vonforst.evmap.*
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.ui.ChargerIconGenerator
import net.vonforst.evmap.ui.availabilityText
@@ -518,7 +519,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
chargepointsText.append(
"${cp.count}× ${
nameForPlugType(
carContext,
carContext.stringProvider(),
cp.type
)
} ${cp.formatPower()}"

View File

@@ -19,8 +19,8 @@ import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupWithNavController
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.fragment.MapFragment
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.utils.LocaleContextWrapper
import net.vonforst.evmap.utils.getLocationFromIntent

View File

@@ -15,14 +15,14 @@ import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.api.chargeprice.ChargePrice
import net.vonforst.evmap.api.chargeprice.ChargepriceChargepointMeta
import net.vonforst.evmap.api.chargeprice.ChargepriceTag
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.databinding.ItemChargepriceBinding
import net.vonforst.evmap.databinding.ItemConnectorButtonBinding
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.ui.CheckableConstraintLayout
import net.vonforst.evmap.viewmodel.FavoritesViewModel
interface Equatable {
override fun equals(other: Any?): Boolean;
override fun equals(other: Any?): Boolean
}
abstract class DataBindingAdapter<T : Equatable>(getKey: ((T) -> Any)? = null) :

View File

@@ -3,12 +3,12 @@ package net.vonforst.evmap.adapter
import android.content.Context
import androidx.core.text.HtmlCompat
import net.vonforst.evmap.R
import net.vonforst.evmap.api.goingelectric.ChargeCard
import net.vonforst.evmap.api.goingelectric.ChargeCardId
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.OpeningHoursDays
import net.vonforst.evmap.bold
import net.vonforst.evmap.joinToSpannedString
import net.vonforst.evmap.model.ChargeCard
import net.vonforst.evmap.model.ChargeCardId
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.OpeningHoursDays
import net.vonforst.evmap.plus
import java.time.ZoneId
import java.time.format.DateTimeFormatter

View File

@@ -12,7 +12,7 @@ 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 net.vonforst.evmap.model.*
import kotlin.math.max
class FiltersAdapter : DataBindingAdapter<FilterWithValue<FilterValue>>() {

View File

@@ -13,7 +13,7 @@ import coil.size.OriginalSize
import coil.size.SizeResolver
import com.ortiz.touchview.TouchImageView
import net.vonforst.evmap.R
import net.vonforst.evmap.api.goingelectric.ChargerPhoto
import net.vonforst.evmap.model.ChargerPhoto
class GalleryAdapter(
@@ -78,13 +78,11 @@ class GalleryAdapter(
(holder.view as TouchImageView).resetZoom()
}
val id = getItem(position).id
val url = "https://api.goingelectric.de/chargepoints/photo/?key=${apikey}" +
"&id=$id" +
if (detailView) {
"&size=1000"
} else {
"&height=${holder.view.height}"
}
val url = if (detailView) {
getItem(position).getUrl(size = 1000)
} else {
getItem(position).getUrl(height = holder.view.height)
}
holder.view.load(
url

View File

@@ -0,0 +1,38 @@
package net.vonforst.evmap.api
import android.content.Context
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import net.vonforst.evmap.model.*
import net.vonforst.evmap.viewmodel.Resource
interface ChargepointApi<T : ReferenceData> {
suspend fun getChargepoints(
bounds: LatLngBounds,
zoom: Float,
filters: FilterValues
): Resource<List<ChargepointListItem>>
suspend fun getChargepointsRadius(
location: LatLng,
radius: Int,
zoom: Float,
filters: FilterValues
): Resource<List<ChargepointListItem>>
suspend fun getChargepointDetail(id: Long): Resource<ChargeLocation>
suspend fun getReferenceData(): Resource<T>
fun getFilters(referenceData: T, sp: StringProvider): List<Filter<FilterValue>>
}
interface StringProvider {
fun getString(id: Int): String
}
fun Context.stringProvider() = object : StringProvider {
override fun getString(id: Int): String {
return this@stringProvider.getString(id)
}
}

View File

@@ -1,11 +1,10 @@
package net.vonforst.evmap.api
import android.content.Context
import androidx.annotation.DrawableRes
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.suspendCancellableCoroutine
import net.vonforst.evmap.R
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.model.Chargepoint
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Response
@@ -56,7 +55,7 @@ private val plugNames = mapOf(
Chargepoint.TESLA_ROADSTER_HPC to R.string.plug_roadster_hpc
)
fun nameForPlugType(ctx: Context, type: String): String =
fun nameForPlugType(ctx: StringProvider, type: String): String =
plugNames[type]?.let {
ctx.getString(it)
} ?: type

View File

@@ -6,12 +6,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.withContext
import net.vonforst.evmap.api.RateLimitInterceptor
import net.vonforst.evmap.api.await
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.viewmodel.FilterValues
import net.vonforst.evmap.model.*
import net.vonforst.evmap.viewmodel.Resource
import net.vonforst.evmap.viewmodel.getMultipleChoiceValue
import net.vonforst.evmap.viewmodel.getSliderValue
import okhttp3.JavaNetCookieJar
import okhttp3.OkHttpClient
import okhttp3.Request

View File

@@ -1,9 +1,9 @@
package net.vonforst.evmap.api.availability
import kotlinx.coroutines.ExperimentalCoroutinesApi
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.api.iterator
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import okhttp3.OkHttpClient
import org.json.JSONObject
import java.io.IOException

View File

@@ -1,8 +1,8 @@
package net.vonforst.evmap.api.availability
import com.squareup.moshi.JsonClass
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.utils.distanceBetween
import okhttp3.OkHttpClient
import retrofit2.Retrofit

View File

@@ -9,7 +9,7 @@ import moe.banana.jsonapi2.JsonApi
import moe.banana.jsonapi2.Resource
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.ui.currency
import kotlin.math.ceil
import kotlin.math.floor

View File

@@ -13,7 +13,7 @@ internal class ChargepointListItemJsonAdapterFactory : JsonAdapter.Factory {
annotations: MutableSet<out Annotation>,
moshi: Moshi
): JsonAdapter<*>? {
if (Types.getRawType(type) == ChargepointListItem::class.java) {
if (Types.getRawType(type) == GEChargepointListItem::class.java) {
return ChargepointListItemJsonAdapter(
moshi
)
@@ -26,18 +26,18 @@ internal class ChargepointListItemJsonAdapterFactory : JsonAdapter.Factory {
internal class ChargepointListItemJsonAdapter(val moshi: Moshi) :
JsonAdapter<ChargepointListItem>() {
JsonAdapter<GEChargepointListItem>() {
private val clusterAdapter =
moshi.adapter<ChargeLocationCluster>(
ChargeLocationCluster::class.java
moshi.adapter<GEChargeLocationCluster>(
GEChargeLocationCluster::class.java
)
private val locationAdapter = moshi.adapter<ChargeLocation>(
ChargeLocation::class.java
private val locationAdapter = moshi.adapter<GEChargeLocation>(
GEChargeLocation::class.java
)
@FromJson
override fun fromJson(reader: JsonReader): ChargepointListItem {
override fun fromJson(reader: JsonReader): GEChargepointListItem {
var clustered = false
reader.peekJson().use { peeked ->
peeked.beginObject()
@@ -61,7 +61,7 @@ internal class ChargepointListItemJsonAdapter(val moshi: Moshi) :
val CLUSTERED: JsonReader.Options = JsonReader.Options.of("clustered")
}
override fun toJson(writer: JsonWriter, value: ChargepointListItem?) {
override fun toJson(writer: JsonWriter, value: GEChargepointListItem?) {
TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
}
}
@@ -94,8 +94,8 @@ internal class JsonObjectOrFalseAdapter<T> private constructor(
JsonReader.Token.BOOLEAN -> when (reader.nextBoolean()) {
false -> null // Response was false
else -> {
if (this.clazz == FaultReport::class.java) {
FaultReport(null, null) as T
if (this.clazz == GEFaultReport::class.java) {
GEFaultReport(null, null) as T
} else {
throw IllegalStateException("Non-false boolean for @JsonObjectOrFalse field")
}
@@ -126,20 +126,20 @@ internal class HoursAdapter {
private val regex = Regex("from (.*) till (.*)")
@FromJson
fun fromJson(str: String): Hours? {
fun fromJson(str: String): GEHours? {
if (str == "closed") {
return Hours(null, null)
return GEHours(null, null)
} else {
val match = regex.find(str)
if (match != null) {
return Hours(
return GEHours(
LocalTime.parse(match.groupValues[1]),
LocalTime.parse(match.groupValues[2])
)
} else {
// I cannot reproduce this case, but it seems to occur once in a while
Log.e("GoingElectricApi", "invalid hours value: " + str)
return Hours(
return GEHours(
LocalTime.MIN, LocalTime.MIN
)
}
@@ -147,7 +147,7 @@ internal class HoursAdapter {
}
@ToJson
fun toJson(value: Hours): String {
fun toJson(value: GEHours): String {
if (value.start == null || value.end == null) {
return "closed"
} else {

View File

@@ -1,9 +1,22 @@
package net.vonforst.evmap.api.goingelectric
import android.content.Context
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import com.facebook.stetho.okhttp3.StethoInterceptor
import com.squareup.moshi.Moshi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.R
import net.vonforst.evmap.api.ChargepointApi
import net.vonforst.evmap.api.StringProvider
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.model.*
import net.vonforst.evmap.ui.cluster
import net.vonforst.evmap.viewmodel.Resource
import net.vonforst.evmap.viewmodel.getClusterDistance
import okhttp3.Cache
import okhttp3.OkHttpClient
import retrofit2.Response
@@ -11,6 +24,8 @@ import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET
import retrofit2.http.Query
import java.io.IOException
import kotlin.math.abs
interface GoingElectricApi {
@GET("chargepoints/")
@@ -31,7 +46,7 @@ interface GoingElectricApi {
@Query("open_twentyfourseven") open247: Boolean = false,
@Query("barrierfree") barrierfree: Boolean = false,
@Query("exclude_faults") excludeFaults: Boolean = false
): Response<ChargepointList>
): Response<GEChargepointList>
@GET("chargepoints/")
suspend fun getChargepointsRadius(
@@ -52,19 +67,19 @@ interface GoingElectricApi {
@Query("open_twentyfourseven") open247: Boolean = false,
@Query("barrierfree") barrierfree: Boolean = false,
@Query("exclude_faults") excludeFaults: Boolean = false
): Response<ChargepointList>
): Response<GEChargepointList>
@GET("chargepoints/")
suspend fun getChargepointDetail(@Query("ge_id") id: Long): Response<ChargepointList>
suspend fun getChargepointDetail(@Query("ge_id") id: Long): Response<GEChargepointList>
@GET("chargepoints/pluglist/")
suspend fun getPlugs(): Response<StringList>
suspend fun getPlugs(): Response<GEStringList>
@GET("chargepoints/networklist/")
suspend fun getNetworks(): Response<StringList>
suspend fun getNetworks(): Response<GEStringList>
@GET("chargepoints/chargecardlist/")
suspend fun getChargeCards(): Response<ChargeCardList>
suspend fun getChargeCards(): Response<GEChargeCardList>
companion object {
private val cacheSize = 10L * 1024 * 1024 // 10MB
@@ -106,3 +121,254 @@ interface GoingElectricApi {
}
}
}
class GoingElectricApiWrapper(
val apikey: String,
baseurl: String = "https://api.goingelectric.de",
context: Context? = null
) : ChargepointApi<GEReferenceData> {
val api = GoingElectricApi.create(apikey, baseurl, context)
override suspend fun getChargepoints(
bounds: LatLngBounds,
zoom: Float,
filters: FilterValues
): Resource<List<ChargepointListItem>> {
val freecharging = filters.getBooleanValue("freecharging")
val freeparking = filters.getBooleanValue("freeparking")
val open247 = filters.getBooleanValue("open_247")
val barrierfree = filters.getBooleanValue("barrierfree")
val excludeFaults = filters.getBooleanValue("exclude_faults")
val minPower = filters.getSliderValue("min_power")
val minConnectors = filters.getSliderValue("min_connectors")
val connectorsVal = filters.getMultipleChoiceValue("connectors")
if (connectorsVal.values.isEmpty() && !connectorsVal.all) {
// no connectors chosen
return Resource.success(emptyList())
}
val connectors = formatMultipleChoice(connectorsVal)
val chargeCardsVal = filters.getMultipleChoiceValue("chargecards")
if (chargeCardsVal.values.isEmpty() && !chargeCardsVal.all) {
// no chargeCards chosen
return Resource.success(emptyList())
}
val chargeCards = formatMultipleChoice(chargeCardsVal)
val networksVal = filters.getMultipleChoiceValue("networks")
if (networksVal.values.isEmpty() && !networksVal.all) {
// no networks chosen
return Resource.success(emptyList())
}
val networks = formatMultipleChoice(networksVal)
val categoriesVal = filters.getMultipleChoiceValue("categories")
if (categoriesVal.values.isEmpty() && !categoriesVal.all) {
// no categories chosen
return Resource.success(emptyList())
}
val categories = formatMultipleChoice(categoriesVal)
// do not use clustering if filters need to be applied locally.
val useClustering = zoom < 13
val geClusteringAvailable = minConnectors <= 1
val useGeClustering = useClustering && geClusteringAvailable
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
var startkey: Int? = null
val data = mutableListOf<GEChargepointListItem>()
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,
open247 = open247,
barrierfree = barrierfree,
excludeFaults = excludeFaults,
plugs = connectors,
chargecards = chargeCards,
networks = networks,
categories = categories,
startkey = startkey
)
if (!response.isSuccessful || response.body()!!.status != "ok") {
return Resource.error(response.message(), null)
} else {
val body = response.body()!!
data.addAll(body.chargelocations)
startkey = body.startkey
}
} catch (e: IOException) {
return Resource.error(e.message, null)
}
} while (startkey != null && startkey < 10000)
var result = data.filter { it ->
// apply filters which GoingElectric does not support natively
if (it is GEChargeLocation) {
it.chargepoints
.filter { it.power >= minPower }
.filter { if (!connectorsVal.all) it.type in connectorsVal.values else true }
.sumBy { it.count } >= minConnectors
} else {
true
}
}.map { it.convert(apikey) } // convert to common model
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(",")
override suspend fun getChargepointsRadius(
location: LatLng,
radius: Int,
zoom: Float,
filters: FilterValues
): Resource<List<ChargepointListItem>> {
TODO("Not yet implemented")
}
override suspend fun getChargepointDetail(id: Long): Resource<ChargeLocation> {
val response = api.getChargepointDetail(id)
return if (response.isSuccessful && response.body()!!.status == "ok" && response.body()!!.chargelocations.size == 1) {
Resource.success(
(response.body()!!.chargelocations[0] as GEChargeLocation).convert(
apikey
)
)
} else {
Resource.error(response.message(), null)
}
}
override suspend fun getReferenceData(): Resource<GEReferenceData> =
withContext(Dispatchers.IO) {
val plugs = async { api.getPlugs() }
val chargeCards = async { api.getChargeCards() }
val networks = async { api.getNetworks() }
val plugsResponse = plugs.await()
val chargeCardsResponse = chargeCards.await()
val networksResponse = networks.await()
val responses = listOf(plugsResponse, chargeCardsResponse, networksResponse)
if (responses.map { it.isSuccessful }.all { it }) {
Resource.success(
GEReferenceData(
plugsResponse.body()!!.result,
networksResponse.body()!!.result,
chargeCardsResponse.body()!!.result
)
)
} else {
Resource.error(responses.find { !it.isSuccessful }!!.message(), null)
}
}
override fun getFilters(
referenceData: GEReferenceData,
sp: StringProvider
): List<Filter<FilterValue>> {
val plugs = referenceData.plugs
val networks = referenceData.networks
val chargeCards = referenceData.chargecards
val plugMap = plugs.map { plug ->
plug to nameForPlugType(sp, plug)
}.toMap()
val networkMap = networks.map { it to it }.toMap()
val chargecardMap = chargeCards.map { it.id.toString() to it.name }.toMap()
val categoryMap = mapOf(
"Autohaus" to sp.getString(R.string.category_car_dealership),
"Autobahnraststätte" to sp.getString(R.string.category_service_on_motorway),
"Autohof" to sp.getString(R.string.category_service_off_motorway),
"Bahnhof" to sp.getString(R.string.category_railway_station),
"Behörde" to sp.getString(R.string.category_public_authorities),
"Campingplatz" to sp.getString(R.string.category_camping),
"Einkaufszentrum" to sp.getString(R.string.category_shopping_mall),
"Ferienwohnung" to sp.getString(R.string.category_holiday_home),
"Flughafen" to sp.getString(R.string.category_airport),
"Freizeitpark" to sp.getString(R.string.category_amusement_park),
"Hotel" to sp.getString(R.string.category_hotel),
"Kino" to sp.getString(R.string.category_cinema),
"Kirche" to sp.getString(R.string.category_church),
"Krankenhaus" to sp.getString(R.string.category_hospital),
"Museum" to sp.getString(R.string.category_museum),
"Parkhaus" to sp.getString(R.string.category_parking_multi),
"Parkplatz" to sp.getString(R.string.category_parking),
"Privater Ladepunkt" to sp.getString(R.string.category_private_charger),
"Rastplatz" to sp.getString(R.string.category_rest_area),
"Restaurant" to sp.getString(R.string.category_restaurant),
"Schwimmbad" to sp.getString(R.string.category_swimming_pool),
"Supermarkt" to sp.getString(R.string.category_supermarket),
"Tankstelle" to sp.getString(R.string.category_petrol_station),
"Tiefgarage" to sp.getString(R.string.category_parking_underground),
"Tierpark" to sp.getString(R.string.category_zoo),
"Wohnmobilstellplatz" to sp.getString(R.string.category_caravan_site)
)
return listOf(
BooleanFilter(sp.getString(R.string.filter_free), "freecharging"),
BooleanFilter(sp.getString(R.string.filter_free_parking), "freeparking"),
BooleanFilter(sp.getString(R.string.filter_open_247), "open_247"),
SliderFilter(
sp.getString(R.string.filter_min_power), "min_power",
powerSteps.size - 1,
mapping = ::mapPower,
inverseMapping = ::mapPowerInverse,
unit = "kW"
),
MultipleChoiceFilter(
sp.getString(R.string.filter_connectors), "connectors",
plugMap,
commonChoices = setOf(Chargepoint.TYPE_2, Chargepoint.CCS, Chargepoint.CHADEMO),
manyChoices = true
),
SliderFilter(
sp.getString(R.string.filter_min_connectors),
"min_connectors",
10,
min = 1
),
MultipleChoiceFilter(
sp.getString(R.string.filter_networks), "networks",
networkMap, manyChoices = true
),
MultipleChoiceFilter(
sp.getString(R.string.categories), "categories",
categoryMap,
manyChoices = true
),
BooleanFilter(sp.getString(R.string.filter_barrierfree), "barrierfree"),
MultipleChoiceFilter(
sp.getString(R.string.filter_chargecards), "chargecards",
chargecardMap, manyChoices = true
),
BooleanFilter(sp.getString(R.string.filter_exclude_faults), "exclude_faults")
)
}
private val powerSteps = listOf(0, 2, 3, 7, 11, 22, 43, 50, 75, 100, 150, 200, 250, 300, 350)
private fun mapPower(i: Int) = powerSteps[i]
private fun mapPowerInverse(power: Int) = powerSteps
.mapIndexed { index, v -> abs(v - power) to index }
.minByOrNull { it.first }?.second ?: 0
}

View File

@@ -1,58 +1,47 @@
package net.vonforst.evmap.api.goingelectric
import android.content.Context
import android.os.Parcelable
import androidx.core.text.HtmlCompat
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.parcelize.Parcelize
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.Equatable
import java.time.DayOfWeek
import net.vonforst.evmap.model.*
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
@JsonClass(generateAdapter = true)
data class ChargepointList(
data class GEChargepointList(
val status: String,
val chargelocations: List<ChargepointListItem>,
val chargelocations: List<GEChargepointListItem>,
@JsonObjectOrFalse val startkey: Int?
)
@JsonClass(generateAdapter = true)
data class StringList(
data class GEStringList(
val status: String,
val result: List<String>
)
@JsonClass(generateAdapter = true)
data class ChargeCardList(
data class GEChargeCardList(
val status: String,
val result: List<ChargeCard>
val result: List<GEChargeCard>
)
sealed class ChargepointListItem
sealed class GEChargepointListItem {
abstract fun convert(apikey: String): ChargepointListItem
}
@JsonClass(generateAdapter = true)
@Entity
data class ChargeLocation(
@Json(name = "ge_id") @PrimaryKey val id: Long,
data class GEChargeLocation(
@Json(name = "ge_id") val id: Long,
val name: String,
@Embedded val coordinates: Coordinate,
@Embedded val address: Address,
val chargepoints: List<Chargepoint>,
val coordinates: GECoordinate,
val address: GEAddress,
val chargepoints: List<GEChargepoint>,
@JsonObjectOrFalse val network: String?,
val url: String,
@Embedded(prefix = "fault_report_") @JsonObjectOrFalse @Json(name = "fault_report") val faultReport: FaultReport?,
@JsonObjectOrFalse @Json(name = "fault_report") val faultReport: GEFaultReport?,
val verified: Boolean,
@Json(name = "barrierfree") val barrierFree: Boolean?,
// only shown in details:
@@ -60,260 +49,156 @@ data class ChargeLocation(
@JsonObjectOrFalse @Json(name = "general_information") val generalInformation: String?,
@JsonObjectOrFalse @Json(name = "ladeweile") val amenities: String?,
@JsonObjectOrFalse @Json(name = "location_description") val locationDescription: String?,
val photos: List<ChargerPhoto>?,
@JsonObjectOrFalse val chargecards: List<ChargeCardId>?,
@Embedded val openinghours: OpeningHours?,
@Embedded val cost: Cost?
) : ChargepointListItem(), Equatable {
/**
* maximum power available from this charger.
*/
val maxPower: Double
get() {
return maxPower()
}
/**
* Gets the maximum power available from certain connectors of this charger.
*/
fun maxPower(connectors: Set<String>? = null): Double {
return chargepoints.filter { connectors?.contains(it.type) ?: true }
.map { it.power }.maxOrNull() ?: 0.0
}
fun isMulti(filteredConnectors: Set<String>? = null): Boolean {
var chargepoints = chargepointsMerged
.filter { filteredConnectors?.contains(it.type) ?: true }
if (maxPower(filteredConnectors) >= 43) {
// fast charger -> only count fast chargers
chargepoints = chargepoints.filter { it.power >= 43 }
}
val connectors = chargepoints.map { it.type }.distinct().toSet()
// check if there is more than one plug for any connector type
val chargepointsPerConnector =
connectors.map { conn -> chargepoints.filter { it.type == conn }.sumBy { it.count } }
return chargepointsPerConnector.any { it > 1 }
}
/**
* Merges chargepoints if they have the same plug and power
*
* This occurs e.g. for Type2 sockets and plugs, which are distinct on the GE website, but not
* separable in the API
*/
val chargepointsMerged: List<Chargepoint>
get() {
val variants = chargepoints.distinctBy { it.power to it.type }
return variants.map { variant ->
val count = chargepoints
.filter { it.type == variant.type && it.power == variant.power }
.sumBy { it.count }
Chargepoint(variant.type, variant.power, count)
}
}
val totalChargepoints: Int
get() = chargepoints.sumBy { it.count }
fun formatChargepoints(): String {
return chargepointsMerged.map {
"${it.count} × ${it.type} ${it.formatPower()}"
}.joinToString(" · ")
val photos: List<GEChargerPhoto>?,
@JsonObjectOrFalse val chargecards: List<GEChargeCardId>?,
val openinghours: GEOpeningHours?,
val cost: GECost?
) : GEChargepointListItem() {
override fun convert(apikey: String): ChargeLocation {
return ChargeLocation(
id,
name,
coordinates.convert(),
address.convert(),
chargepoints.map { it.convert() },
network,
url,
faultReport?.convert(),
verified,
barrierFree,
operator,
generalInformation,
amenities,
locationDescription,
photos?.map { it.convert(apikey) },
chargecards?.map { it.convert() },
openinghours?.convert(),
cost?.convert()
)
}
}
@JsonClass(generateAdapter = true)
data class Cost(
data class GECost(
val freecharging: Boolean,
val freeparking: Boolean,
@JsonObjectOrFalse @Json(name = "description_short") val descriptionShort: String?,
@JsonObjectOrFalse @Json(name = "description_long") val descriptionLong: String?
) {
fun getStatusText(ctx: Context, emoji: Boolean = false): CharSequence {
val charging =
if (freecharging) ctx.getString(R.string.free) else ctx.getString(R.string.paid)
val parking =
if (freeparking) ctx.getString(R.string.free) else ctx.getString(R.string.paid)
return if (emoji) {
"$charging · \uD83C\uDD7F $parking"
} else {
HtmlCompat.fromHtml(ctx.getString(R.string.cost_detail, charging, parking), 0)
}
}
fun convert() = Cost(freecharging, freeparking, descriptionShort, descriptionLong)
}
@JsonClass(generateAdapter = true)
data class OpeningHours(
data class GEOpeningHours(
@Json(name = "24/7") val twentyfourSeven: Boolean,
@JsonObjectOrFalse val description: String?,
@Embedded val days: OpeningHoursDays?
val days: GEOpeningHoursDays?
) {
val isEmpty: Boolean
get() = description == "Leider noch keine Informationen zu Öffnungszeiten vorhanden."
&& days == null && !twentyfourSeven
fun getStatusText(ctx: Context): CharSequence {
if (twentyfourSeven) {
return HtmlCompat.fromHtml(ctx.getString(R.string.open_247), 0)
} else if (days != null) {
val hours = days.getHoursForDate(LocalDate.now())
if (hours.start == null || hours.end == null) {
return HtmlCompat.fromHtml(ctx.getString(R.string.closed), 0)
}
val now = LocalTime.now()
if (hours.start.isBefore(now) && hours.end.isAfter(now)) {
return HtmlCompat.fromHtml(
ctx.getString(
R.string.open_closesat,
hours.end.toString()
), 0
)
} else if (hours.end.isBefore(now)) {
return HtmlCompat.fromHtml(ctx.getString(R.string.closed), 0)
} else {
return HtmlCompat.fromHtml(
ctx.getString(
R.string.closed_opensat,
hours.start.toString()
), 0
)
}
} else {
return ""
}
}
fun convert() = OpeningHours(twentyfourSeven, description, days?.convert())
}
@JsonClass(generateAdapter = true)
data class OpeningHoursDays(
@Embedded(prefix = "mo") val monday: Hours,
@Embedded(prefix = "tu") val tuesday: Hours,
@Embedded(prefix = "we") val wednesday: Hours,
@Embedded(prefix = "th") val thursday: Hours,
@Embedded(prefix = "fr") val friday: Hours,
@Embedded(prefix = "sa") val saturday: Hours,
@Embedded(prefix = "su") val sunday: Hours,
@Embedded(prefix = "ho") val holiday: Hours
data class GEOpeningHoursDays(
val monday: GEHours,
val tuesday: GEHours,
val wednesday: GEHours,
val thursday: GEHours,
val friday: GEHours,
val saturday: GEHours,
val sunday: GEHours,
val holiday: GEHours
) {
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 (dayOfWeek) {
DayOfWeek.MONDAY -> monday
DayOfWeek.TUESDAY -> tuesday
DayOfWeek.WEDNESDAY -> wednesday
DayOfWeek.THURSDAY -> thursday
DayOfWeek.FRIDAY -> friday
DayOfWeek.SATURDAY -> saturday
DayOfWeek.SUNDAY -> sunday
null -> holiday
}
}
fun convert() = OpeningHoursDays(
monday.convert(),
tuesday.convert(),
wednesday.convert(),
thursday.convert(),
friday.convert(),
saturday.convert(),
sunday.convert(),
holiday.convert()
)
}
data class Hours(
data class GEHours(
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"
}
}
fun convert() = Hours(start, end)
}
@JsonClass(generateAdapter = true)
data class GEChargerPhoto(val id: String) {
fun convert(apikey: String): ChargerPhoto = GEChargerPhotoAdapter(id, apikey)
}
@Parcelize
data class ChargerPhoto(val id: String) : Parcelable
@JsonClass(generateAdapter = true)
data class ChargeLocationCluster(
val clusterCount: Int,
val coordinates: Coordinate
) : ChargepointListItem()
@JsonClass(generateAdapter = true)
data class Coordinate(val lat: Double, val lng: Double) {
fun formatDMS(): String {
return "${dms(lat, false)}, ${dms(lng, true)}"
}
private fun dms(value: Double, lon: Boolean): String {
val hemisphere = if (lon) {
if (value >= 0) "E" else "W"
} else {
if (value >= 0) "N" else "S"
}
val d = abs(value)
val degrees = floor(d).toInt()
val minutes = floor((d - degrees) * 60).toInt()
val seconds = ((d - degrees) * 60 - minutes) * 60
return "%d°%02d'%02.1f\"%s".format(Locale.ENGLISH, degrees, minutes, seconds, hemisphere)
}
fun formatDecimal(): String {
return "%.6f, %.6f".format(Locale.ENGLISH, lat, lng)
private class GEChargerPhotoAdapter(override val id: String, private val apikey: String) :
ChargerPhoto(id) {
override fun getUrl(height: Int?, width: Int?, size: Int?): String {
return "https://api.goingelectric.de/chargepoints/photo/?key=${apikey}&id=$id" +
when {
size != null -> "&size=$size"
height != null -> "&height=$height"
width != null -> "&width=$width"
else -> ""
}
}
}
@JsonClass(generateAdapter = true)
data class Address(
data class GEChargeLocationCluster(
val clusterCount: Int,
val coordinates: GECoordinate
) : GEChargepointListItem() {
override fun convert(apikey: String) =
ChargeLocationCluster(clusterCount, coordinates.convert())
}
@JsonClass(generateAdapter = true)
data class GECoordinate(val lat: Double, val lng: Double) {
fun convert() = Coordinate(lat, lng)
}
@JsonClass(generateAdapter = true)
data class GEAddress(
@JsonObjectOrFalse val city: String?,
@JsonObjectOrFalse val country: String?,
@JsonObjectOrFalse val postcode: String?,
@JsonObjectOrFalse val street: String?
) {
override fun toString(): String {
return "${street ?: ""}, ${postcode ?: ""} ${city ?: ""}"
}
fun convert() = Address(city, country, postcode, street)
}
@JsonClass(generateAdapter = true)
data class Chargepoint(val type: String, val power: Double, val count: Int) : Equatable {
fun formatPower(): String {
val powerFmt = if (power - power.toInt() == 0.0) {
"%.0f".format(power)
} else {
"%.1f".format(power)
}
return "$powerFmt kW"
}
companion object {
const val TYPE_1 = "Typ1"
const val TYPE_2 = "Typ2"
const val TYPE_3 = "Typ3"
const val CCS = "CCS"
const val SCHUKO = "Schuko"
const val CHADEMO = "CHAdeMO"
const val SUPERCHARGER = "Tesla Supercharger"
const val CEE_BLAU = "CEE Blau"
const val CEE_ROT = "CEE Rot"
const val TESLA_ROADSTER_HPC = "Tesla HPC"
}
data class GEChargepoint(val type: String, val power: Double, val count: Int) {
fun convert() = Chargepoint(type, power, count)
}
@JsonClass(generateAdapter = true)
data class FaultReport(val created: Instant?, val description: String?)
data class GEFaultReport(val created: Instant?, val description: String?) {
fun convert() = FaultReport(created, description)
}
@Entity
@JsonClass(generateAdapter = true)
data class ChargeCard(
@Entity(tableName = "ChargeCard")
data class GEChargeCard(
@Json(name = "card_id") @PrimaryKey val id: Long,
val name: String,
val url: String
)
) {
fun convert() = ChargeCard(id, name, url)
}
@JsonClass(generateAdapter = true)
data class ChargeCardId(
data class GEChargeCardId(
val id: Long
)
) {
fun convert() = ChargeCardId(id)
}
data class GEReferenceData(
val plugs: List<String>,
val networks: List<String>,
val chargecards: List<GEChargeCard>
) : ReferenceData()

View File

@@ -20,10 +20,10 @@ import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.ChargepriceAdapter
import net.vonforst.evmap.adapter.CheckableConnectorAdapter
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import net.vonforst.evmap.databinding.FragmentChargepriceBinding
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.viewmodel.ChargepriceViewModel
import net.vonforst.evmap.viewmodel.Status
import net.vonforst.evmap.viewmodel.viewModelFactory

View File

@@ -16,8 +16,8 @@ import coil.memory.MemoryCache
import com.ortiz.touchview.TouchImageView
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.GalleryAdapter
import net.vonforst.evmap.api.goingelectric.ChargerPhoto
import net.vonforst.evmap.databinding.FragmentGalleryBinding
import net.vonforst.evmap.model.ChargerPhoto
import net.vonforst.evmap.viewmodel.GalleryViewModel

View File

@@ -66,12 +66,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.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.ChargeLocationCluster
import net.vonforst.evmap.api.goingelectric.ChargepointListItem
import net.vonforst.evmap.autocomplete.handleAutocompleteResult
import net.vonforst.evmap.autocomplete.launchAutocomplete
import net.vonforst.evmap.databinding.FragmentMapBinding
import net.vonforst.evmap.model.*
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.ChargerIconGenerator
import net.vonforst.evmap.ui.ClusterIconGenerator

View File

@@ -0,0 +1,286 @@
package net.vonforst.evmap.model
import android.content.Context
import android.os.Parcelable
import androidx.core.text.HtmlCompat
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.PrimaryKey
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.Equatable
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
sealed class ChargepointListItem
@Entity
data class ChargeLocation(
@PrimaryKey val id: Long,
val name: String,
@Embedded val coordinates: Coordinate,
@Embedded val address: Address,
val chargepoints: List<Chargepoint>,
val network: String?,
val url: String,
@Embedded(prefix = "fault_report_") val faultReport: FaultReport?,
val verified: Boolean,
val barrierFree: Boolean?,
// only shown in details:
val operator: String?,
val generalInformation: String?,
val amenities: String?,
val locationDescription: String?,
val photos: List<ChargerPhoto>?,
val chargecards: List<ChargeCardId>?,
@Embedded val openinghours: OpeningHours?,
@Embedded val cost: Cost?
) : ChargepointListItem(), Equatable {
/**
* maximum power available from this charger.
*/
val maxPower: Double
get() {
return maxPower()
}
/**
* Gets the maximum power available from certain connectors of this charger.
*/
fun maxPower(connectors: Set<String>? = null): Double {
return chargepoints.filter { connectors?.contains(it.type) ?: true }
.map { it.power }.maxOrNull() ?: 0.0
}
fun isMulti(filteredConnectors: Set<String>? = null): Boolean {
var chargepoints = chargepointsMerged
.filter { filteredConnectors?.contains(it.type) ?: true }
if (maxPower(filteredConnectors) >= 43) {
// fast charger -> only count fast chargers
chargepoints = chargepoints.filter { it.power >= 43 }
}
val connectors = chargepoints.map { it.type }.distinct().toSet()
// check if there is more than one plug for any connector type
val chargepointsPerConnector =
connectors.map { conn -> chargepoints.filter { it.type == conn }.sumBy { it.count } }
return chargepointsPerConnector.any { it > 1 }
}
/**
* Merges chargepoints if they have the same plug and power
*
* This occurs e.g. for Type2 sockets and plugs, which are distinct on the GE website, but not
* separable in the API
*/
val chargepointsMerged: List<Chargepoint>
get() {
val variants = chargepoints.distinctBy { it.power to it.type }
return variants.map { variant ->
val count = chargepoints
.filter { it.type == variant.type && it.power == variant.power }
.sumBy { it.count }
Chargepoint(variant.type, variant.power, count)
}
}
val totalChargepoints: Int
get() = chargepoints.sumBy { it.count }
fun formatChargepoints(): String {
return chargepointsMerged.map {
"${it.count} × ${it.type} ${it.formatPower()}"
}.joinToString(" · ")
}
}
data class Cost(
val freecharging: Boolean,
val freeparking: Boolean,
val descriptionShort: String?,
val descriptionLong: String?
) {
fun getStatusText(ctx: Context, emoji: Boolean = false): CharSequence {
val charging =
if (freecharging) ctx.getString(R.string.free) else ctx.getString(R.string.paid)
val parking =
if (freeparking) ctx.getString(R.string.free) else ctx.getString(R.string.paid)
return if (emoji) {
"$charging · \uD83C\uDD7F $parking"
} else {
HtmlCompat.fromHtml(ctx.getString(R.string.cost_detail, charging, parking), 0)
}
}
}
data class OpeningHours(
val twentyfourSeven: Boolean,
val description: String?,
@Embedded val days: OpeningHoursDays?
) {
val isEmpty: Boolean
get() = description == "Leider noch keine Informationen zu Öffnungszeiten vorhanden."
&& days == null && !twentyfourSeven
fun getStatusText(ctx: Context): CharSequence {
if (twentyfourSeven) {
return HtmlCompat.fromHtml(ctx.getString(R.string.open_247), 0)
} else if (days != null) {
val hours = days.getHoursForDate(LocalDate.now())
if (hours.start == null || hours.end == null) {
return HtmlCompat.fromHtml(ctx.getString(R.string.closed), 0)
}
val now = LocalTime.now()
if (hours.start.isBefore(now) && hours.end.isAfter(now)) {
return HtmlCompat.fromHtml(
ctx.getString(
R.string.open_closesat,
hours.end.toString()
), 0
)
} else if (hours.end.isBefore(now)) {
return HtmlCompat.fromHtml(ctx.getString(R.string.closed), 0)
} else {
return HtmlCompat.fromHtml(
ctx.getString(
R.string.closed_opensat,
hours.start.toString()
), 0
)
}
} else {
return ""
}
}
}
data class OpeningHoursDays(
@Embedded(prefix = "mo") val monday: Hours,
@Embedded(prefix = "tu") val tuesday: Hours,
@Embedded(prefix = "we") val wednesday: Hours,
@Embedded(prefix = "th") val thursday: Hours,
@Embedded(prefix = "fr") val friday: Hours,
@Embedded(prefix = "sa") val saturday: Hours,
@Embedded(prefix = "su") val sunday: Hours,
@Embedded(prefix = "ho") val holiday: Hours
) {
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 (dayOfWeek) {
DayOfWeek.MONDAY -> monday
DayOfWeek.TUESDAY -> tuesday
DayOfWeek.WEDNESDAY -> wednesday
DayOfWeek.THURSDAY -> thursday
DayOfWeek.FRIDAY -> friday
DayOfWeek.SATURDAY -> saturday
DayOfWeek.SUNDAY -> sunday
null -> holiday
}
}
}
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"
}
}
}
abstract class ChargerPhoto(open val id: String) : Parcelable {
abstract fun getUrl(height: Int? = null, width: Int? = null, size: Int? = null): String
}
data class ChargeLocationCluster(
val clusterCount: Int,
val coordinates: Coordinate
) : ChargepointListItem()
data class Coordinate(val lat: Double, val lng: Double) {
fun formatDMS(): String {
return "${dms(lat, false)}, ${dms(lng, true)}"
}
private fun dms(value: Double, lon: Boolean): String {
val hemisphere = if (lon) {
if (value >= 0) "E" else "W"
} else {
if (value >= 0) "N" else "S"
}
val d = abs(value)
val degrees = floor(d).toInt()
val minutes = floor((d - degrees) * 60).toInt()
val seconds = ((d - degrees) * 60 - minutes) * 60
return "%d°%02d'%02.1f\"%s".format(Locale.ENGLISH, degrees, minutes, seconds, hemisphere)
}
fun formatDecimal(): String {
return "%.6f, %.6f".format(Locale.ENGLISH, lat, lng)
}
}
data class Address(
val city: String?,
val country: String?,
val postcode: String?,
val street: String?
) {
override fun toString(): String {
return "${street ?: ""}, ${postcode ?: ""} ${city ?: ""}"
}
}
data class Chargepoint(val type: String, val power: Double, val count: Int) : Equatable {
fun formatPower(): String {
val powerFmt = if (power - power.toInt() == 0.0) {
"%.0f".format(power)
} else {
"%.1f".format(power)
}
return "$powerFmt kW"
}
companion object {
const val TYPE_1 = "Typ1"
const val TYPE_2 = "Typ2"
const val TYPE_3 = "Typ3"
const val CCS = "CCS"
const val SCHUKO = "Schuko"
const val CHADEMO = "CHAdeMO"
const val SUPERCHARGER = "Tesla Supercharger"
const val CEE_BLAU = "CEE Blau"
const val CEE_ROT = "CEE Rot"
const val TESLA_ROADSTER_HPC = "Tesla HPC"
}
}
data class FaultReport(val created: Instant?, val description: String?)
@Entity
data class ChargeCard(
@PrimaryKey val id: Long,
val name: String,
val url: String
)
data class ChargeCardId(
val id: Long
)

View File

@@ -0,0 +1,130 @@
package net.vonforst.evmap.model
import androidx.databinding.BaseObservable
import androidx.room.Entity
import androidx.room.ForeignKey
import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.storage.FilterProfile
import kotlin.reflect.KClass
sealed class Filter<out T : FilterValue> : Equatable {
abstract val name: String
abstract val key: String
abstract val valueClass: KClass<out T>
abstract fun defaultValue(): T
}
data class BooleanFilter(override val name: String, override val key: String) :
Filter<BooleanFilterValue>() {
override val valueClass: KClass<BooleanFilterValue> = BooleanFilterValue::class
override fun defaultValue() = BooleanFilterValue(key, false)
}
data class MultipleChoiceFilter(
override val name: String,
override val key: String,
val choices: Map<String, String>,
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)
}
data class SliderFilter(
override val name: String,
override val key: String,
val max: Int,
val min: Int = 0,
val mapping: ((Int) -> Int) = { it },
val inverseMapping: ((Int) -> Int) = { it },
val unit: String? = ""
) : Filter<SliderFilterValue>() {
override val valueClass: KClass<SliderFilterValue> = SliderFilterValue::class
override fun defaultValue() = SliderFilterValue(key, min)
}
sealed class FilterValue : BaseObservable(), Equatable {
abstract val key: String
var profile: Long = FILTERS_CUSTOM
}
@Entity(
foreignKeys = [ForeignKey(
entity = FilterProfile::class,
parentColumns = arrayOf("id"),
childColumns = arrayOf("profile"),
onDelete = ForeignKey.CASCADE
)],
primaryKeys = ["key", "profile"]
)
data class BooleanFilterValue(
override val key: String,
var value: Boolean
) : FilterValue()
@Entity(
foreignKeys = [ForeignKey(
entity = FilterProfile::class,
parentColumns = arrayOf("id"),
childColumns = arrayOf("profile"),
onDelete = ForeignKey.CASCADE
)],
primaryKeys = ["key", "profile"]
)
data class MultipleChoiceFilterValue(
override val key: String,
var values: MutableSet<String>,
var all: Boolean
) : FilterValue() {
override fun equals(other: Any?): Boolean {
if (other == null || other !is MultipleChoiceFilterValue) return false
if (key != other.key) return false
return if (all) {
other.all
} else {
!other.all && values == other.values
}
}
override fun hashCode(): Int {
var result = key.hashCode()
result = 31 * result + all.hashCode()
result = 31 * result + if (all) 0 else values.hashCode()
return result
}
}
@Entity(
foreignKeys = [ForeignKey(
entity = FilterProfile::class,
parentColumns = arrayOf("id"),
childColumns = arrayOf("profile"),
onDelete = ForeignKey.CASCADE
)],
primaryKeys = ["key", "profile"]
)
data class SliderFilterValue(
override val key: String,
var value: Int
) : FilterValue()
data class FilterWithValue<T : FilterValue>(val filter: Filter<T>, val value: T) : Equatable
typealias FilterValues = List<FilterWithValue<out FilterValue>>
fun FilterValues.getBooleanValue(key: String) =
(this.find { it.value.key == key }!!.value as BooleanFilterValue).value
fun FilterValues.getSliderValue(key: String) =
(this.find { it.value.key == key }!!.value as SliderFilterValue).value
fun FilterValues.getMultipleChoiceFilter(key: String) =
this.find { it.value.key == key }!!.filter as MultipleChoiceFilter
fun FilterValues.getMultipleChoiceValue(key: String) =
this.find { it.value.key == key }!!.value as MultipleChoiceFilterValue
const val FILTERS_DISABLED = -2L
const val FILTERS_CUSTOM = -1L

View File

@@ -0,0 +1,4 @@
package net.vonforst.evmap.model
open class ReferenceData {
}

View File

@@ -1,54 +0,0 @@
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.io.IOException
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
try {
val response = api.getChargeCards()
if (!response.isSuccessful) return
for (card in response.body()!!.result) {
dao.insert(card)
}
prefs.lastChargeCardUpdate = Instant.now()
} catch (e: IOException) {
// ignore, and retry next time
e.printStackTrace()
return
}
}
}

View File

@@ -2,7 +2,7 @@ package net.vonforst.evmap.storage
import androidx.lifecycle.LiveData
import androidx.room.*
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.model.ChargeLocation
@Dao
interface ChargeLocationsDao {

View File

@@ -7,12 +7,8 @@ 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.FILTERS_CUSTOM
import net.vonforst.evmap.viewmodel.MultipleChoiceFilterValue
import net.vonforst.evmap.viewmodel.SliderFilterValue
import net.vonforst.evmap.api.goingelectric.GEChargeCard
import net.vonforst.evmap.model.*
@Database(
entities = [
@@ -21,9 +17,9 @@ import net.vonforst.evmap.viewmodel.SliderFilterValue
MultipleChoiceFilterValue::class,
SliderFilterValue::class,
FilterProfile::class,
Plug::class,
Network::class,
ChargeCard::class
GEPlug::class,
GENetwork::class,
GEChargeCard::class
], version = 11
)
@TypeConverters(Converters::class)
@@ -31,9 +27,9 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun chargeLocationsDao(): ChargeLocationsDao
abstract fun filterValueDao(): FilterValueDao
abstract fun filterProfileDao(): FilterProfileDao
abstract fun plugDao(): PlugDao
abstract fun networkDao(): NetworkDao
abstract fun chargeCardDao(): ChargeCardDao
// GoingElectric API specific
abstract fun geReferenceDataDao(): GEReferenceDataDao
companion object {
private lateinit var context: Context

View File

@@ -3,7 +3,7 @@ package net.vonforst.evmap.storage
import androidx.lifecycle.LiveData
import androidx.room.*
import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.viewmodel.FILTERS_CUSTOM
import net.vonforst.evmap.model.FILTERS_CUSTOM
@Entity(
indices = [Index(value = ["name"], unique = true)]

View File

@@ -4,7 +4,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.room.*
import net.vonforst.evmap.viewmodel.*
import net.vonforst.evmap.model.*
@Dao
abstract class FilterValueDao {

View File

@@ -0,0 +1,119 @@
package net.vonforst.evmap.storage
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.room.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.goingelectric.GEChargeCard
import net.vonforst.evmap.api.goingelectric.GEReferenceData
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.viewmodel.Status
import java.time.Duration
import java.time.Instant
@Entity(tableName = "Network")
data class GENetwork(@PrimaryKey val name: String)
@Entity(tableName = "Plug")
data class GEPlug(@PrimaryKey val name: String)
@Dao
abstract class GEReferenceDataDao {
// NETWORKS
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insert(vararg networks: GENetwork)
@Query("DELETE FROM network")
abstract fun deleteAllNetworks()
@Transaction
open suspend fun updateNetworks(networks: List<GENetwork>) {
deleteAllNetworks()
for (network in networks) {
insert(network)
}
}
@Query("SELECT * FROM network")
abstract fun getAllNetworks(): LiveData<List<GENetwork>>
// PLUGS
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insert(vararg plugs: GEPlug)
@Query("DELETE FROM plug")
abstract fun deleteAllPlugs()
@Transaction
open suspend fun updatePlugs(plugs: List<GEPlug>) {
deleteAllPlugs()
for (plug in plugs) {
insert(plug)
}
}
@Query("SELECT * FROM plug")
abstract fun getAllPlugs(): LiveData<List<GEPlug>>
// CHARGE CARDS
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insert(vararg chargeCards: GEChargeCard)
@Query("DELETE FROM chargecard")
abstract fun deleteAllChargeCards()
@Transaction
open suspend fun updateChargeCards(chargeCards: List<GEChargeCard>) {
deleteAllChargeCards()
for (chargeCard in chargeCards) {
insert(chargeCard)
}
}
@Query("SELECT * FROM chargecard")
abstract fun getAllChargeCards(): LiveData<List<GEChargeCard>>
}
class GEReferenceDataRepository(
private val api: GoingElectricApiWrapper, private val scope: CoroutineScope,
private val dao: GEReferenceDataDao, private val prefs: PreferenceDataSource
) {
fun getReferenceData(): LiveData<GEReferenceData> {
scope.launch {
updateData()
}
val plugs = dao.getAllPlugs()
val networks = dao.getAllNetworks()
val chargeCards = dao.getAllChargeCards()
return MediatorLiveData<GEReferenceData>().apply {
listOf(chargeCards, networks, plugs).map { source ->
addSource(source) { _ ->
val p = plugs.value ?: return@addSource
val n = networks.value ?: return@addSource
val cc = chargeCards.value ?: return@addSource
value = GEReferenceData(p.map { it.name }, n.map { it.name }, cc)
}
}
}
}
private suspend fun updateData() {
if (Duration.between(
prefs.lastGeReferenceDataUpdate,
Instant.now()
) < Duration.ofDays(1)
) return
val response = api.getReferenceData()
if (response.status == Status.ERROR) return // ignore and retry next time
val data = response.data!!
dao.updateNetworks(data.networks.map { GENetwork(it) })
dao.updatePlugs(data.plugs.map { GEPlug(it) })
dao.updateChargeCards(data.chargecards)
prefs.lastGeReferenceDataUpdate = Instant.now()
}
}

View File

@@ -1,56 +0,0 @@
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.io.IOException
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
try {
val response = api.getNetworks()
if (!response.isSuccessful) return
for (name in response.body()!!.result) {
dao.insert(Network(name))
}
prefs.lastNetworkUpdate = Instant.now()
} catch (e: IOException) {
// ignore, and retry next time
e.printStackTrace()
return
}
}
}

View File

@@ -1,56 +0,0 @@
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.io.IOException
import java.time.Duration
import java.time.Instant
@Entity
data class Plug(@PrimaryKey val name: String)
@Dao
interface PlugDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(vararg plugs: Plug)
@Delete
suspend fun delete(vararg plugs: Plug)
@Query("SELECT * FROM plug")
fun getAllPlugs(): LiveData<List<Plug>>
}
class PlugRepository(
private val api: GoingElectricApi, private val scope: CoroutineScope,
private val dao: PlugDao, private val prefs: PreferenceDataSource
) {
fun getPlugs(): LiveData<List<Plug>> {
scope.launch {
updatePlugs()
}
return dao.getAllPlugs()
}
private suspend fun updatePlugs() {
if (Duration.between(prefs.lastPlugUpdate, Instant.now()) < Duration.ofDays(1)) return
try {
val response = api.getPlugs()
if (!response.isSuccessful) return
for (name in response.body()!!.result) {
dao.insert(Plug(name))
}
prefs.lastPlugUpdate = Instant.now()
} catch (e: IOException) {
// ignore, and retry next time
e.printStackTrace()
return
}
}
}

View File

@@ -4,8 +4,8 @@ import android.content.Context
import androidx.preference.PreferenceManager
import com.car2go.maps.AnyMap
import net.vonforst.evmap.R
import net.vonforst.evmap.viewmodel.FILTERS_CUSTOM
import net.vonforst.evmap.viewmodel.FILTERS_DISABLED
import net.vonforst.evmap.model.FILTERS_CUSTOM
import net.vonforst.evmap.model.FILTERS_DISABLED
import java.time.Instant
class PreferenceDataSource(val context: Context) {
@@ -17,22 +17,10 @@ class PreferenceDataSource(val context: Context) {
sp.edit().putBoolean("navigate_use_maps", value).apply()
}
var lastPlugUpdate: Instant
get() = Instant.ofEpochMilli(sp.getLong("last_plug_update", 0L))
var lastGeReferenceDataUpdate: Instant
get() = Instant.ofEpochMilli(sp.getLong("last_ge_reference_data_update", 0L))
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()
sp.edit().putLong("last_ge_reference_data_update", value.toEpochMilli()).apply()
}
/**

View File

@@ -3,9 +3,9 @@ package net.vonforst.evmap.storage
import androidx.room.TypeConverter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import net.vonforst.evmap.api.goingelectric.ChargeCardId
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.api.goingelectric.ChargerPhoto
import net.vonforst.evmap.model.ChargeCardId
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.model.ChargerPhoto
import java.time.Instant
import java.time.LocalTime

View File

@@ -3,10 +3,10 @@ package net.vonforst.evmap.ui;
import com.car2go.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
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.ChargeLocationCluster
import net.vonforst.evmap.model.ChargepointListItem
import net.vonforst.evmap.model.Coordinate
fun cluster(

View File

@@ -7,7 +7,7 @@ import androidx.interpolator.view.animation.FastOutLinearInInterpolator
import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
import com.car2go.maps.model.Marker
import net.vonforst.evmap.R
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.model.ChargeLocation
import kotlin.math.max
fun getMarkerTint(

View File

@@ -6,8 +6,8 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import moe.banana.jsonapi2.HasOne
import net.vonforst.evmap.api.chargeprice.*
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.Chargepoint
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.storage.PreferenceDataSource
import java.io.IOException
import java.util.*

View File

@@ -10,8 +10,8 @@ import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.utils.distanceBetween

View File

@@ -5,6 +5,7 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.FilterProfile
import net.vonforst.evmap.storage.PreferenceDataSource

View File

@@ -1,125 +1,15 @@
package net.vonforst.evmap.viewmodel
import android.app.Application
import androidx.databinding.BaseObservable
import androidx.lifecycle.*
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.ForeignKey.CASCADE
import kotlinx.coroutines.launch
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.api.nameForPlugType
import net.vonforst.evmap.api.goingelectric.GEReferenceData
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.*
import net.vonforst.evmap.storage.*
import kotlin.math.abs
import kotlin.reflect.KClass
import kotlin.reflect.full.cast
val powerSteps = listOf(0, 2, 3, 7, 11, 22, 43, 50, 75, 100, 150, 200, 250, 300, 350)
internal fun mapPower(i: Int) = powerSteps[i]
internal fun mapPowerInverse(power: Int) = powerSteps
.mapIndexed { index, v -> abs(v - power) to index }
.minByOrNull { it.first }?.second ?: 0
internal fun getFilters(
application: Application,
plugs: LiveData<List<Plug>>,
networks: LiveData<List<Network>>,
chargeCards: LiveData<List<ChargeCard>>
): LiveData<List<Filter<FilterValue>>> {
return MediatorLiveData<List<Filter<FilterValue>>>().apply {
listOf(plugs, networks, chargeCards).forEach { source ->
addSource(source) { _ ->
buildFilters(plugs, networks, chargeCards, application)
}
}
}
}
private fun MediatorLiveData<List<Filter<FilterValue>>>.buildFilters(
plugs: LiveData<List<Plug>>,
networks: LiveData<List<Network>>,
chargeCards: LiveData<List<ChargeCard>>,
application: Application
) {
val plugMap = plugs.value?.map { plug ->
plug.name to nameForPlugType(application, 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
val categoryMap = mapOf(
"Autohaus" to application.getString(R.string.category_car_dealership),
"Autobahnraststätte" to application.getString(R.string.category_service_on_motorway),
"Autohof" to application.getString(R.string.category_service_off_motorway),
"Bahnhof" to application.getString(R.string.category_railway_station),
"Behörde" to application.getString(R.string.category_public_authorities),
"Campingplatz" to application.getString(R.string.category_camping),
"Einkaufszentrum" to application.getString(R.string.category_shopping_mall),
"Ferienwohnung" to application.getString(R.string.category_holiday_home),
"Flughafen" to application.getString(R.string.category_airport),
"Freizeitpark" to application.getString(R.string.category_amusement_park),
"Hotel" to application.getString(R.string.category_hotel),
"Kino" to application.getString(R.string.category_cinema),
"Kirche" to application.getString(R.string.category_church),
"Krankenhaus" to application.getString(R.string.category_hospital),
"Museum" to application.getString(R.string.category_museum),
"Parkhaus" to application.getString(R.string.category_parking_multi),
"Parkplatz" to application.getString(R.string.category_parking),
"Privater Ladepunkt" to application.getString(R.string.category_private_charger),
"Rastplatz" to application.getString(R.string.category_rest_area),
"Restaurant" to application.getString(R.string.category_restaurant),
"Schwimmbad" to application.getString(R.string.category_swimming_pool),
"Supermarkt" to application.getString(R.string.category_supermarket),
"Tankstelle" to application.getString(R.string.category_petrol_station),
"Tiefgarage" to application.getString(R.string.category_parking_underground),
"Tierpark" to application.getString(R.string.category_zoo),
"Wohnmobilstellplatz" to application.getString(R.string.category_caravan_site)
)
value = listOf(
BooleanFilter(application.getString(R.string.filter_free), "freecharging"),
BooleanFilter(application.getString(R.string.filter_free_parking), "freeparking"),
BooleanFilter(application.getString(R.string.filter_open_247), "open_247"),
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),
manyChoices = true
),
SliderFilter(
application.getString(R.string.filter_min_connectors),
"min_connectors",
10,
min = 1
),
MultipleChoiceFilter(
application.getString(R.string.filter_networks), "networks",
networkMap, manyChoices = true
),
MultipleChoiceFilter(
application.getString(R.string.categories), "categories",
categoryMap,
manyChoices = true
),
BooleanFilter(application.getString(R.string.filter_barrierfree), "barrierfree"),
MultipleChoiceFilter(
application.getString(R.string.filter_chargecards), "chargecards",
chargecardMap, manyChoices = true
),
BooleanFilter(application.getString(R.string.filter_exclude_faults), "exclude_faults")
)
}
internal fun filtersWithValue(
filters: LiveData<List<Filter<FilterValue>>>,
filterValues: LiveData<List<FilterValue>>
@@ -140,21 +30,22 @@ internal fun filtersWithValue(
class FilterViewModel(application: Application, geApiKey: String) :
AndroidViewModel(application) {
private var api = GoingElectricApi.create(geApiKey, context = application)
private var api = GoingElectricApiWrapper(geApiKey, context = application)
private var db = AppDatabase.getInstance(application)
private var prefs = PreferenceDataSource(application)
private val plugs: LiveData<List<Plug>> by lazy {
PlugRepository(api, viewModelScope, db.plugDao(), prefs).getPlugs()
private val referenceData: LiveData<out ReferenceData> by lazy {
GEReferenceDataRepository(
api,
viewModelScope,
db.geReferenceDataDao(),
prefs
).getReferenceData()
}
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, networks, chargeCards)
private val filters = MediatorLiveData<List<Filter<FilterValue>>>().apply {
addSource(referenceData) { data ->
value = api.getFilters(data as GEReferenceData, application.stringProvider())
}
}
private val filterValues: LiveData<List<FilterValue>> by lazy {
@@ -212,126 +103,4 @@ class FilterViewModel(application: Application, geApiKey: String) :
// set selected profile
prefs.filterStatus = profileId
}
}
sealed class Filter<out T : FilterValue> : Equatable {
abstract val name: String
abstract val key: String
abstract val valueClass: KClass<out T>
abstract fun defaultValue(): T
}
data class BooleanFilter(override val name: String, override val key: String) :
Filter<BooleanFilterValue>() {
override val valueClass: KClass<BooleanFilterValue> = BooleanFilterValue::class
override fun defaultValue() = BooleanFilterValue(key, false)
}
data class MultipleChoiceFilter(
override val name: String,
override val key: String,
val choices: Map<String, String>,
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)
}
data class SliderFilter(
override val name: String,
override val key: String,
val max: Int,
val min: Int = 0,
val mapping: ((Int) -> Int) = { it },
val inverseMapping: ((Int) -> Int) = { it },
val unit: String? = ""
) : Filter<SliderFilterValue>() {
override val valueClass: KClass<SliderFilterValue> = SliderFilterValue::class
override fun defaultValue() = SliderFilterValue(key, min)
}
sealed class FilterValue : BaseObservable(), Equatable {
abstract val key: String
var profile: Long = FILTERS_CUSTOM
}
@Entity(
foreignKeys = [ForeignKey(
entity = FilterProfile::class,
parentColumns = arrayOf("id"),
childColumns = arrayOf("profile"),
onDelete = CASCADE
)],
primaryKeys = ["key", "profile"]
)
data class BooleanFilterValue(
override val key: String,
var value: Boolean
) : FilterValue()
@Entity(
foreignKeys = [ForeignKey(
entity = FilterProfile::class,
parentColumns = arrayOf("id"),
childColumns = arrayOf("profile"),
onDelete = CASCADE
)],
primaryKeys = ["key", "profile"]
)
data class MultipleChoiceFilterValue(
override val key: String,
var values: MutableSet<String>,
var all: Boolean
) : FilterValue() {
override fun equals(other: Any?): Boolean {
if (other == null || other !is MultipleChoiceFilterValue) return false
if (key != other.key) return false
return if (all) {
other.all
} else {
!other.all && values == other.values
}
}
override fun hashCode(): Int {
var result = key.hashCode()
result = 31 * result + all.hashCode()
result = 31 * result + if (all) 0 else values.hashCode()
return result
}
}
@Entity(
foreignKeys = [ForeignKey(
entity = FilterProfile::class,
parentColumns = arrayOf("id"),
childColumns = arrayOf("profile"),
onDelete = CASCADE
)],
primaryKeys = ["key", "profile"]
)
data class SliderFilterValue(
override val key: String,
var value: Int
) : FilterValue()
data class FilterWithValue<T : FilterValue>(val filter: Filter<T>, val value: T) : Equatable
typealias FilterValues = List<FilterWithValue<out FilterValue>>
fun FilterValues.getBooleanValue(key: String) =
(this.find { it.value.key == key }!!.value as BooleanFilterValue).value
fun FilterValues.getSliderValue(key: String) =
(this.find { it.value.key == key }!!.value as SliderFilterValue).value
fun FilterValues.getMultipleChoiceFilter(key: String) =
this.find { it.value.key == key }!!.filter as MultipleChoiceFilter
fun FilterValues.getMultipleChoiceValue(key: String) =
this.find { it.value.key == key }!!.value as MultipleChoiceFilterValue
const val FILTERS_DISABLED = -2L
const val FILTERS_CUSTOM = -1L
}

View File

@@ -5,16 +5,17 @@ import androidx.lifecycle.*
import com.car2go.maps.AnyMap
import com.car2go.maps.model.LatLng
import com.car2go.maps.model.LatLngBounds
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.availability.ChargeLocationStatus
import net.vonforst.evmap.api.availability.getAvailability
import net.vonforst.evmap.api.goingelectric.ChargeCard
import net.vonforst.evmap.api.goingelectric.ChargeLocation
import net.vonforst.evmap.api.goingelectric.ChargepointListItem
import net.vonforst.evmap.api.goingelectric.GoingElectricApi
import net.vonforst.evmap.storage.*
import net.vonforst.evmap.ui.cluster
import net.vonforst.evmap.api.goingelectric.GEReferenceData
import net.vonforst.evmap.api.goingelectric.GoingElectricApiWrapper
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.*
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.FilterProfile
import net.vonforst.evmap.storage.GEReferenceDataRepository
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.utils.distanceBetween
import java.io.IOException
@@ -33,7 +34,7 @@ internal fun getClusterDistance(zoom: Float): Int? {
}
class MapViewModel(application: Application, geApiKey: String) : AndroidViewModel(application) {
private var api = GoingElectricApi.create(geApiKey, context = application)
private var api = GoingElectricApiWrapper(geApiKey, context = application)
private var db = AppDatabase.getInstance(application)
private var prefs = PreferenceDataSource(application)
@@ -56,16 +57,19 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
}
}
}
private val plugs: LiveData<List<Plug>> by lazy {
PlugRepository(api, viewModelScope, db.plugDao(), prefs).getPlugs()
private val referenceData: LiveData<out ReferenceData> by lazy {
GEReferenceDataRepository(
api,
viewModelScope,
db.geReferenceDataDao(),
prefs
).getReferenceData()
}
private val networks: LiveData<List<Network>> by lazy {
NetworkRepository(api, viewModelScope, db.networkDao(), prefs).getNetworks()
private val filters = MediatorLiveData<List<Filter<FilterValue>>>().apply {
addSource(referenceData) { data ->
value = api.getFilters(data as GEReferenceData, application.stringProvider())
}
}
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<FilterValues> by lazy {
filtersWithValue(filters, filterValues)
@@ -78,10 +82,14 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
val chargeCardMap: LiveData<Map<Long, ChargeCard>> by lazy {
MediatorLiveData<Map<Long, ChargeCard>>().apply {
value = null
addSource(chargeCards) {
value = chargeCards.value?.map {
it.id to it
}?.toMap()
addSource(referenceData) { data ->
value = if (data is GEReferenceData) {
data.chargecards.map {
it.id to it.convert()
}.toMap()
} else {
null
}
}
}
}
@@ -286,10 +294,19 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
val mapPosition = data.first
val filters = data.second
val result = getChargepointsWithFilters(mapPosition.bounds, mapPosition.zoom, filters)
filteredConnectors.value = result.second
filteredChargeCards.value = result.third
chargepoints.value = result.first
var result = api.getChargepoints(mapPosition.bounds, mapPosition.zoom, filters)
if (result.status == Status.ERROR && result.data == null) {
// keep old results if new data could not be loaded
result = Resource.error(result.message, chargepoints.value?.data)
}
chargepoints.value = result
val chargeCardsVal = filters.getMultipleChoiceValue("chargecards")
filteredChargeCards.value =
if (chargeCardsVal.all) null else chargeCardsVal.values.map { it.toLong() }.toSet()
val connectorsVal = filters.getMultipleChoiceValue("connectors")
filteredConnectors.value = if (connectorsVal.all) null else connectorsVal.values
}
private fun loadChargepoints(
@@ -299,125 +316,6 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
chargepointLoader(Pair(mapPosition, filters))
}
private suspend fun getChargepointsWithFilters(
bounds: LatLngBounds,
zoom: Float,
filters: FilterValues
): Triple<Resource<List<ChargepointListItem>>, Set<String>?, Set<Long>?> {
val freecharging = filters.getBooleanValue("freecharging")
val freeparking = filters.getBooleanValue("freeparking")
val open247 = filters.getBooleanValue("open_247")
val barrierfree = filters.getBooleanValue("barrierfree")
val excludeFaults = filters.getBooleanValue("exclude_faults")
val minPower = filters.getSliderValue("min_power")
val minConnectors = filters.getSliderValue("min_connectors")
val connectorsVal = filters.getMultipleChoiceValue("connectors")
if (connectorsVal.values.isEmpty() && !connectorsVal.all) {
// no connectors chosen
return Triple(Resource.success(emptyList()), null, null)
}
val connectors = formatMultipleChoice(connectorsVal)
val filteredConnectors = if (connectorsVal.all) null else connectorsVal.values
val chargeCardsVal = filters.getMultipleChoiceValue("chargecards")
if (chargeCardsVal.values.isEmpty() && !chargeCardsVal.all) {
// no chargeCards chosen
return Triple(Resource.success(emptyList()), filteredConnectors, null)
}
val chargeCards = formatMultipleChoice(chargeCardsVal)
val filteredChargeCards =
if (chargeCardsVal.all) null else chargeCardsVal.values.map { it.toLong() }.toSet()
val networksVal = filters.getMultipleChoiceValue("networks")
if (networksVal.values.isEmpty() && !networksVal.all) {
// no networks chosen
return Triple(Resource.success(emptyList()), filteredConnectors, filteredChargeCards)
}
val networks = formatMultipleChoice(networksVal)
val categoriesVal = filters.getMultipleChoiceValue("categories")
if (categoriesVal.values.isEmpty() && !categoriesVal.all) {
// no categories chosen
return Triple(Resource.success(emptyList()), filteredConnectors, filteredChargeCards)
}
val categories = formatMultipleChoice(categoriesVal)
// do not use clustering if filters need to be applied locally.
val useClustering = zoom < 13
val geClusteringAvailable = minConnectors <= 1
val useGeClustering = useClustering && geClusteringAvailable
val clusterDistance = if (useClustering) getClusterDistance(zoom) else null
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,
open247 = open247,
barrierfree = barrierfree,
excludeFaults = excludeFaults,
plugs = connectors,
chargecards = chargeCards,
networks = networks,
categories = categories,
startkey = startkey
)
if (!response.isSuccessful || response.body()!!.status != "ok") {
return Triple(
Resource.error(response.message(), chargepoints.value?.data),
null,
null
)
} else {
val body = response.body()!!
data.addAll(body.chargelocations)
startkey = body.startkey
}
} catch (e: IOException) {
return Triple(
Resource.error(e.message, chargepoints.value?.data),
filteredConnectors,
filteredChargeCards
)
}
} while (startkey != null && startkey < 10000)
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 Triple(Resource.success(result), filteredConnectors, filteredChargeCards)
}
private fun formatMultipleChoice(connectorsVal: MultipleChoiceFilterValue) =
if (connectorsVal.all) null else connectorsVal.values.joinToString(",")
private suspend fun loadAvailability(charger: ChargeLocation) {
availability.value = Resource.loading(null)
availability.value = getAvailability(charger)
@@ -427,13 +325,7 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
chargerDetails.value = Resource.loading(null)
viewModelScope.launch {
try {
val response = api.getChargepointDetail(charger.id)
if (!response.isSuccessful || response.body()!!.status != "ok") {
chargerDetails.value = Resource.error(response.message(), null)
} else {
chargerDetails.value =
Resource.success(response.body()!!.chargelocations[0] as ChargeLocation)
}
chargerDetails.value = api.getChargepointDetail(charger.id)
} catch (e: IOException) {
chargerDetails.value = Resource.error(e.message, null)
e.printStackTrace()
@@ -446,19 +338,11 @@ class MapViewModel(application: Application, geApiKey: String) : AndroidViewMode
chargerSparse.value = null
viewModelScope.launch {
val response = api.getChargepointDetail(chargerId)
if (!response.isSuccessful || response.body()!!.status != "ok") {
chargerSparse.value = null
chargerDetails.value = Resource.error(response.message(), null)
chargerDetails.value = response
if (response.status == Status.SUCCESS) {
chargerSparse.value = response.data
} else {
val chargers = response.body()!!.chargelocations
if (chargers.isNotEmpty()) {
val charger = chargers[0] as ChargeLocation
chargerDetails.value =
Resource.success(charger)
chargerSparse.value = charger} else {
chargerDetails.value = Resource.error("not found", null)
chargerSparse.value = null
}
chargerSparse.value = null
}
}
}

View File

@@ -5,11 +5,11 @@
<data>
<import type="net.vonforst.evmap.api.goingelectric.ChargeLocation" />
<import type="net.vonforst.evmap.model.ChargeLocation" />
<import type="net.vonforst.evmap.api.goingelectric.Chargepoint" />
<import type="net.vonforst.evmap.model.Chargepoint" />
<import type="net.vonforst.evmap.api.goingelectric.ChargeCard" />
<import type="net.vonforst.evmap.model.ChargeCard" />
<import type="net.vonforst.evmap.api.availability.ChargeLocationStatus" />

View File

@@ -4,7 +4,7 @@
<data>
<import type="net.vonforst.evmap.api.goingelectric.ChargerPhoto" />
<import type="net.vonforst.evmap.model.ChargerPhoto" />
<import type="java.util.List" />

View File

@@ -5,7 +5,7 @@
<data>
<import type="net.vonforst.evmap.api.goingelectric.Chargepoint" />
<import type="net.vonforst.evmap.model.Chargepoint" />
<import type="net.vonforst.evmap.ui.BindingAdaptersKt" />

View File

@@ -11,7 +11,7 @@
<variable
name="hours"
type="net.vonforst.evmap.api.goingelectric.OpeningHoursDays" />
type="net.vonforst.evmap.model.OpeningHoursDays" />
<variable
name="dayOfWeek"

View File

@@ -5,11 +5,11 @@
<data>
<import type="net.vonforst.evmap.viewmodel.BooleanFilter" />
<import type="net.vonforst.evmap.model.BooleanFilter" />
<import type="net.vonforst.evmap.viewmodel.BooleanFilterValue" />
<import type="net.vonforst.evmap.model.BooleanFilterValue" />
<import type="net.vonforst.evmap.viewmodel.FilterWithValue" />
<import type="net.vonforst.evmap.model.FilterWithValue" />
<variable
name="item"

View File

@@ -5,11 +5,11 @@
<data>
<import type="net.vonforst.evmap.viewmodel.MultipleChoiceFilter" />
<import type="net.vonforst.evmap.model.MultipleChoiceFilter" />
<import type="net.vonforst.evmap.viewmodel.MultipleChoiceFilterValue" />
<import type="net.vonforst.evmap.model.MultipleChoiceFilterValue" />
<import type="net.vonforst.evmap.viewmodel.FilterWithValue" />
<import type="net.vonforst.evmap.model.FilterWithValue" />
<variable
name="item"

View File

@@ -5,11 +5,11 @@
<data>
<import type="net.vonforst.evmap.viewmodel.MultipleChoiceFilter" />
<import type="net.vonforst.evmap.model.MultipleChoiceFilter" />
<import type="net.vonforst.evmap.viewmodel.MultipleChoiceFilterValue" />
<import type="net.vonforst.evmap.model.MultipleChoiceFilterValue" />
<import type="net.vonforst.evmap.viewmodel.FilterWithValue" />
<import type="net.vonforst.evmap.model.FilterWithValue" />
<variable
name="item"

View File

@@ -5,11 +5,11 @@
<data>
<import type="net.vonforst.evmap.viewmodel.SliderFilter" />
<import type="net.vonforst.evmap.model.SliderFilter" />
<import type="net.vonforst.evmap.viewmodel.SliderFilterValue" />
<import type="net.vonforst.evmap.model.SliderFilterValue" />
<import type="net.vonforst.evmap.viewmodel.FilterWithValue" />
<import type="net.vonforst.evmap.model.FilterWithValue" />
<variable
name="item"