remove native Chargeprice integration

fixes #320
This commit is contained in:
johan12345
2025-10-15 20:32:11 +02:00
committed by Johan von Forstner
parent f6f2b15f41
commit 21e23efb50
29 changed files with 14 additions and 3041 deletions

View File

@@ -21,7 +21,7 @@ android {
minSdk = 21
targetSdk = 36
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
versionCode = 264
versionCode = 266
versionName = "2.0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="chargeprice_api_url">https://staging-api.chargeprice.app/v1/</string>
<string name="chargeprice_key">20c0d68918c9dc96c564784b711a6570</string>
</resources>

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">EVMap</string>
<string name="app_name">EVMap (debug)</string>
</resources>

View File

@@ -1,27 +1,16 @@
package net.vonforst.evmap.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.chip.Chip
import net.vonforst.evmap.BR
import net.vonforst.evmap.R
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.api.chargeprice.ChargePrice
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
import net.vonforst.evmap.api.chargeprice.ChargepriceChargepointMeta
import net.vonforst.evmap.api.chargeprice.ChargepriceTag
import net.vonforst.evmap.databinding.ItemChargepriceBinding
import net.vonforst.evmap.databinding.ItemChargepriceVehicleChipBinding
import net.vonforst.evmap.databinding.ItemConnectorButtonBinding
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.ui.CheckableConstraintLayout
import java.time.Instant
interface Equatable {
@@ -106,141 +95,4 @@ class ConnectorDetailsAdapter : DataBindingAdapter<ConnectorDetailsAdapter.Conne
Equatable
override fun getItemViewType(position: Int): Int = R.layout.dialog_connector_details_item
}
class ChargepriceAdapter :
DataBindingAdapter<ChargePrice>() {
val viewPool = RecyclerView.RecycledViewPool()
var meta: ChargepriceChargepointMeta? = null
set(value) {
field = value
notifyDataSetChanged()
}
var myTariffs: Set<String>? = null
set(value) {
field = value
notifyDataSetChanged()
}
var myTariffsAll: Boolean? = null
set(value) {
field = value
notifyDataSetChanged()
}
override fun getItemViewType(position: Int): Int = R.layout.item_chargeprice
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder<ChargePrice> {
val holder = super.onCreateViewHolder(parent, viewType)
val binding = holder.binding as ItemChargepriceBinding
binding.rvTags.apply {
adapter = ChargepriceTagsAdapter()
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false).apply {
recycleChildrenOnDetach = true
}
itemAnimator = null
setRecycledViewPool(viewPool)
}
return holder
}
override fun bind(holder: ViewHolder<ChargePrice>, item: ChargePrice) {
super.bind(holder, item)
(holder.binding as ItemChargepriceBinding).apply {
this.meta = this@ChargepriceAdapter.meta
this.myTariffs = this@ChargepriceAdapter.myTariffs
this.myTariffsAll = this@ChargepriceAdapter.myTariffsAll
}
}
}
class CheckableConnectorAdapter : DataBindingAdapter<Chargepoint>() {
private var checkedItem: Int? = 0
var enabledConnectors: List<String>? = null
get() = field
set(value) {
field = value
checkedItem?.let {
if (value != null && getItem(it).type !in value) {
checkedItem = currentList.indexOfFirst {
it.type in value
}.takeIf { it != -1 }
onCheckedItemChangedListener?.invoke(getCheckedItem())
}
}
notifyDataSetChanged()
}
override fun getItemViewType(position: Int): Int = R.layout.item_connector_button
override fun onBindViewHolder(holder: ViewHolder<Chargepoint>, position: Int) {
val item = getItem(position)
super.bind(holder, item)
val binding = holder.binding as ItemConnectorButtonBinding
binding.enabled = enabledConnectors?.let { item.type in it } ?: true
val root = binding.root as CheckableConstraintLayout
root.setOnCheckedChangeListener { _, _ -> }
root.isChecked = checkedItem == position
root.setOnClickListener {
root.isChecked = true
}
root.setOnCheckedChangeListener { _, checked: Boolean ->
if (checked) {
checkedItem = holder.bindingAdapterPosition.takeIf { it != -1 }
root.post {
notifyDataSetChanged()
}
getCheckedItem()?.let { onCheckedItemChangedListener?.invoke(it) }
}
}
}
fun getCheckedItem(): Chargepoint? = checkedItem?.let { getItem(it) }
fun setCheckedItem(item: Chargepoint?) {
checkedItem = item?.let { currentList.indexOf(item) }.takeIf { it != -1 }
}
var onCheckedItemChangedListener: ((Chargepoint?) -> Unit)? = null
}
class ChargepriceTagsAdapter() :
DataBindingAdapter<ChargepriceTag>() {
override fun getItemViewType(position: Int): Int = R.layout.item_chargeprice_tag
}
class CheckableChargepriceCarAdapter : DataBindingAdapter<ChargepriceCar>() {
private var checkedItem: ChargepriceCar? = null
override fun getItemViewType(position: Int): Int = R.layout.item_chargeprice_vehicle_chip
override fun onBindViewHolder(holder: ViewHolder<ChargepriceCar>, position: Int) {
val item = getItem(position)
super.bind(holder, item)
val binding = holder.binding as ItemChargepriceVehicleChipBinding
val root = binding.root as Chip
root.isChecked = checkedItem == item
root.setOnClickListener {
root.isChecked = true
}
root.setOnCheckedChangeListener { _, checked: Boolean ->
if (checked && item != checkedItem) {
checkedItem = item
root.post {
notifyDataSetChanged()
onCheckedItemChangedListener?.invoke(getCheckedItem()!!)
}
}
}
}
fun getCheckedItem(): ChargepriceCar? = checkedItem
fun setCheckedItem(item: ChargepriceCar?) {
checkedItem = item
}
var onCheckedItemChangedListener: ((ChargepriceCar?) -> Unit)? = null
}

View File

@@ -1,99 +1,16 @@
package net.vonforst.evmap.api.chargeprice
import android.content.Context
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
import jsonapi.Document
import jsonapi.JsonApiFactory
import jsonapi.retrofit.DocumentConverterFactory
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.model.ChargeLocation
import okhttp3.Cache
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import java.util.*
import java.util.Locale
interface ChargepriceApi {
@POST("charge_prices")
suspend fun getChargePrices(
@Body @jsonapi.retrofit.Document request: ChargepriceRequest,
@Header("Accept-Language") language: String
): Document<List<ChargePrice>>
@GET("vehicles")
@jsonapi.retrofit.Document
suspend fun getVehicles(): List<ChargepriceCar>
@GET("tariffs")
@jsonapi.retrofit.Document
suspend fun getTariffs(): List<ChargepriceTariff>
@POST("user_feedback")
suspend fun userFeedback(@Body @jsonapi.retrofit.Document feedback: ChargepriceUserFeedback)
companion object {
private val cacheSize = 1L * 1024 * 1024 // 1MB
val supportedLanguages = setOf("de", "en", "fr", "nl")
private val DATA_SOURCE_GOINGELECTRIC = "going_electric"
private val DATA_SOURCE_OPENCHARGEMAP = "open_charge_map"
private val jsonApiAdapterFactory = JsonApiFactory.Builder()
.addType(ChargepriceRequest::class.java)
.addType(ChargepriceTariff::class.java)
.addType(ChargepriceBrand::class.java)
.addType(ChargePrice::class.java)
.addType(ChargepriceCar::class.java)
.build()
val moshi = Moshi.Builder()
.add(jsonApiAdapterFactory)
.add(
PolymorphicJsonAdapterFactory.of(ChargepriceUserFeedback::class.java, "type")
.withSubtype(ChargepriceMissingPriceFeedback::class.java, "missing_price")
.withSubtype(ChargepriceWrongPriceFeedback::class.java, "wrong_price")
.withSubtype(ChargepriceMissingVehicleFeedback::class.java, "missing_vehicle")
)
.build()
fun create(
apikey: String,
baseurl: String = "https://api.chargeprice.app/v1/",
context: Context? = null
): ChargepriceApi {
val client = OkHttpClient.Builder().apply {
addInterceptor { chain ->
// add API key to every request
val original = chain.request()
val new = original.newBuilder()
.header("API-Key", apikey)
.header("Content-Type", "application/json")
.build()
chain.proceed(new)
}
if (BuildConfig.DEBUG) {
addDebugInterceptors()
}
if (context != null) {
cache(Cache(context.cacheDir, cacheSize))
}
}.build()
val retrofit = Retrofit.Builder()
.baseUrl(baseurl)
.addConverterFactory(DocumentConverterFactory.create())
.addConverterFactory(MoshiConverterFactory.create(moshi))
.client(client)
.build()
return retrofit.create(ChargepriceApi::class.java)
}
fun getChargepriceLanguage(): String {
val locale = Locale.getDefault().language
return if (supportedLanguages.contains(locale)) {

View File

@@ -1,466 +0,0 @@
package net.vonforst.evmap.api.chargeprice
import android.content.Context
import android.os.Parcel
import android.os.Parcelable
import android.util.Patterns
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import jsonapi.*
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.WriteWith
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.Equatable
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.ui.currency
import kotlin.math.ceil
import kotlin.math.floor
@Resource("charge_price_request")
@JsonClass(generateAdapter = true)
data class ChargepriceRequest(
@Json(name = "data_adapter")
val dataAdapter: String,
val station: ChargepriceStation,
val options: ChargepriceOptions,
@ToMany("tariffs")
val tariffs: List<ChargepriceTariff>? = null,
@ToOne("vehicle")
val vehicle: ChargepriceCar? = null,
@RelationshipsObject var relationships: Relationships? = null
)
@JsonClass(generateAdapter = true)
data class ChargepriceStation(
val longitude: Double,
val latitude: Double,
val country: String?,
val network: String?,
@Json(name = "charge_points") val chargePoints: List<ChargepriceChargepoint>
) {
companion object {
fun fromEvmap(
charger: ChargeLocation,
compatibleConnectors: List<String>,
): ChargepriceStation {
if (charger.chargepriceData == null) throw IllegalArgumentException()
val plugTypes =
charger.chargepriceData.plugTypes ?: charger.chargepoints.map { it.type }
return ChargepriceStation(
charger.coordinates.lng,
charger.coordinates.lat,
charger.chargepriceData.country,
charger.chargepriceData.network,
charger.chargepoints.zip(plugTypes)
.filter { equivalentPlugTypes(it.first.type).any { it in compatibleConnectors } }
.map { ChargepriceChargepoint(it.first.power ?: 0.0, it.second) }
)
}
}
}
@JsonClass(generateAdapter = true)
data class ChargepriceChargepoint(
val power: Double,
val plug: String
)
@JsonClass(generateAdapter = true)
data class ChargepriceOptions(
@Json(name = "max_monthly_fees") val maxMonthlyFees: Double? = null,
val energy: Double? = null,
val duration: Int? = null,
@Json(name = "battery_range") val batteryRange: List<Double>? = null,
@Json(name = "car_ac_phases") val carAcPhases: Int? = null,
val currency: String? = null,
@Json(name = "start_time") val startTime: Int? = null,
@Json(name = "allow_unbalanced_load") val allowUnbalancedLoad: Boolean? = null,
@Json(name = "provider_customer_tariffs") val providerCustomerTariffs: Boolean? = null,
@Json(name = "show_price_unavailable") val showPriceUnavailable: Boolean? = null,
@Json(name = "show_all_brand_restricted_tariffs") val showAllBrandRestrictedTariffs: Boolean? = null
)
@Resource("tariff")
@Parcelize
@JsonClass(generateAdapter = true)
data class ChargepriceTariff(
@Id val id_: String?,
val provider: String,
val name: String,
@Json(name = "direct_payment")
val directPayment: Boolean = false,
@Json(name = "provider_customer_tariff")
val providerCustomerTariff: Boolean = false,
@Json(name = "supported_countries")
val supportedCountries: Set<String>,
@Json(name = "charge_card_id")
val chargeCardId: String?, // GE charge card ID
) : Parcelable {
val id: String
get() = id_!!
}
@JsonClass(generateAdapter = true)
@Resource("car")
@Parcelize
data class ChargepriceCar(
@Id val id_: String?,
val name: String,
val brand: String,
@Json(name = "dc_charge_ports")
val dcChargePorts: List<String>,
@Json(name = "usable_battery_size")
val usableBatterySize: Float,
@Json(name = "ac_max_power")
val acMaxPower: Float,
@Json(name = "dc_max_power")
val dcMaxPower: Float?
) : Equatable, Parcelable {
fun formatSpecs(): String = buildString {
append("%.0f kWh".format(usableBatterySize))
append(" | ")
append("AC %.0f kW".format(acMaxPower))
dcMaxPower?.let {
append(" | ")
append("DC %.0f kW".format(it))
}
}
companion object {
private val acConnectors = listOf(
Chargepoint.CEE_BLAU,
Chargepoint.CEE_ROT,
Chargepoint.SCHUKO,
Chargepoint.TYPE_1,
Chargepoint.TYPE_2_UNKNOWN,
Chargepoint.TYPE_2_SOCKET,
Chargepoint.TYPE_2_PLUG
)
private val plugMapping = mapOf(
"ccs" to Chargepoint.CCS_UNKNOWN,
"tesla_suc" to Chargepoint.SUPERCHARGER,
"tesla_ccs" to Chargepoint.CCS_UNKNOWN,
"chademo" to Chargepoint.CHADEMO
)
}
val id: String
get() = id_!!
val compatibleEvmapConnectors: List<String>
get() = dcChargePorts.mapNotNull {
plugMapping[it]
}.plus(acConnectors)
}
@JsonClass(generateAdapter = true)
@Resource("brand")
@Parcelize
data class ChargepriceBrand(
@Id val id: String?
) : Parcelable
@JsonClass(generateAdapter = true)
@Resource("charge_price")
@Parcelize
data class ChargePrice(
val provider: String,
@Json(name = "tariff_name")
val tariffName: String,
val url: String,
@Json(name = "monthly_min_sales")
val monthlyMinSales: Double = 0.0,
@Json(name = "total_monthly_fee")
val totalMonthlyFee: Double = 0.0,
@Json(name = "flat_rate")
val flatRate: Boolean = false,
@Json(name = "direct_payment")
val directPayment: Boolean = false,
@Json(name = "provider_customer_tariff")
val providerCustomerTariff: Boolean = false,
val currency: String,
@Json(name = "start_time")
val startTime: Int = 0,
val tags: List<ChargepriceTag>,
@Json(name = "charge_point_prices")
val chargepointPrices: List<ChargepointPrice>,
@Json(name = "branding")
val branding: ChargepriceBranding? = null,
@RelationshipsObject
val relationships: @WriteWith<RelationshipsParceler>() Relationships? = null,
) : Equatable, Cloneable, Parcelable {
val tariffId: String?
get() = (relationships?.get("tariff") as? Relationship.ToOne)?.data?.id
fun formatMonthlyFees(ctx: Context): String {
return listOfNotNull(
if (totalMonthlyFee > 0) {
ctx.getString(R.string.chargeprice_base_fee, totalMonthlyFee, currency(currency))
} else null,
if (monthlyMinSales > 0) {
ctx.getString(R.string.chargeprice_min_spend, monthlyMinSales, currency(currency))
} else null
).joinToString(", ")
}
}
/**
* Parceler implementation for the Relationships object.
* Note that this ignores certain fields that we don't need (links, meta, etc.)
*/
internal object RelationshipsParceler : Parceler<Relationships?> {
override fun create(parcel: Parcel): Relationships? {
if (parcel.readInt() == 0) return null
val nMembers = parcel.readInt()
val members = (0 until nMembers).associate { _ ->
val key = parcel.readString()!!
val value = if (parcel.readInt() == 0) {
val type = parcel.readString()
val id = parcel.readString()
val ri = if (type != null && id != null) {
ResourceIdentifier(type, id)
} else null
Relationship.ToOne(ri)
} else {
val size = parcel.readInt()
val ris = (0 until size).map { _ ->
val type = parcel.readString()!!
val id = parcel.readString()!!
ResourceIdentifier(type, id)
}
Relationship.ToMany(ris)
}
key to value
}
return Relationships(members)
}
override fun Relationships?.write(parcel: Parcel, flags: Int) {
if (this == null) {
parcel.writeInt(0)
return
} else {
parcel.writeInt(1)
}
parcel.writeInt(members.size)
for (member in this.members) {
parcel.writeString(member.key)
when (val value = member.value) {
is Relationship.ToOne -> {
parcel.writeInt(0)
parcel.writeString(value.data?.type)
parcel.writeString(value.data?.id)
}
is Relationship.ToMany -> {
parcel.writeInt(1)
parcel.writeInt(value.data.size)
for (ri in value.data) {
parcel.writeString(ri.type)
parcel.writeString(ri.id)
}
}
}
}
}
}
@JsonClass(generateAdapter = true)
@Parcelize
data class ChargepointPrice(
val power: Double,
val plug: String,
val price: Double?,
@Json(name = "price_distribution") val priceDistribution: PriceDistribution,
@Json(name = "blocking_fee_start") val blockingFeeStart: Int?,
@Json(name = "no_price_reason") var noPriceReason: String?
) : Parcelable {
fun formatDistribution(ctx: Context): String {
fun percent(value: Double): String {
return ctx.getString(R.string.percent_format, value * 100) + "\u00a0"
}
fun time(value: Int): String {
val h = floor(value.toDouble() / 60).toInt()
val min = ceil(value.toDouble() % 60).toInt()
return if (h == 0 && min > 0) "${min}min";
// be slightly sloppy (3:01 is shown as 3h) to save space
else if (h > 0 && (min == 0 || min == 1)) "${h}h";
else "%d:%02dh".format(h, min)
}
// based on https://github.com/chargeprice/chargeprice-client/blob/d420bb2f216d9ad91a210a36dd0859a368a8229a/src/views/priceList.js
with(priceDistribution) {
return listOfNotNull(
if (session != null && session > 0.0) {
(if (session < 1) percent(session) else "") + ctx.getString(R.string.chargeprice_session_fee)
} else null,
if (kwh != null && kwh > 0.0 && !isOnlyKwh) {
(if (kwh < 1) percent(kwh) else "") + ctx.getString(R.string.chargeprice_per_kwh)
} else null,
if (minute != null && minute > 0.0) {
(if (minute < 1) percent(minute) else "") + ctx.getString(R.string.chargeprice_per_minute) +
if (blockingFeeStart != null) {
" (${
ctx.getString(
R.string.chargeprice_blocking_fee,
time(blockingFeeStart)
)
})"
} else ""
} else null,
if ((minute == null || minute == 0.0) && blockingFeeStart != null) {
ctx.getString(R.string.chargeprice_blocking_fee, time(blockingFeeStart))
} else null
).joinToString(" +\u00a0")
}
}
}
@JsonClass(generateAdapter = true)
@Parcelize
data class ChargepriceBranding(
@Json(name = "background_color") val backgroundColor: String,
@Json(name = "text_color") val textColor: String,
@Json(name = "logo_url") val logoUrl: String
) : Parcelable
@JsonClass(generateAdapter = true)
@Parcelize
data class PriceDistribution(val kwh: Double?, val session: Double?, val minute: Double?) :
Parcelable {
val isOnlyKwh
get() = kwh != null && kwh > 0 && (session == null || session == 0.0) && (minute == null || minute == 0.0)
}
@JsonClass(generateAdapter = true)
@Parcelize
data class ChargepriceTag(val kind: String, val text: String, val url: String?) : Equatable,
Parcelable
@JsonClass(generateAdapter = true)
data class ChargepriceMeta(
@Json(name = "charge_points") val chargePoints: List<ChargepriceChargepointMeta>
)
enum class ChargepriceInclude {
@Json(name = "filter")
FILTER,
@Json(name = "always")
ALWAYS,
@Json(name = "exclusive")
EXCLUSIVE
}
@JsonClass(generateAdapter = true)
@Parcelize
data class ChargepriceRequestTariffMeta(
val include: ChargepriceInclude
) : Parcelable
@JsonClass(generateAdapter = true)
data class ChargepriceChargepointMeta(
val power: Double,
val plug: String,
val energy: Double,
val duration: Double
)
@Resource("user_feedback")
sealed class ChargepriceUserFeedback(
val notes: String,
val email: String,
val context: String,
val language: String
) {
init {
if (email.isBlank() || email.length > 100 || !Patterns.EMAIL_ADDRESS.matcher(email)
.matches()
) {
throw IllegalArgumentException("invalid email")
}
if (!ChargepriceApi.supportedLanguages.contains(language)) {
throw IllegalArgumentException("invalid language")
}
if (context.length > 500) throw IllegalArgumentException("invalid context")
if (notes.length > 1000) throw IllegalArgumentException("invalid notes")
}
}
@JsonClass(generateAdapter = true)
@Resource(type = "missing_price")
class ChargepriceMissingPriceFeedback(
val tariff: String,
val cpo: String,
val price: String,
@Json(name = "poi_link") val poiLink: String,
notes: String,
email: String,
context: String,
language: String
) : ChargepriceUserFeedback(notes, email, context, language) {
init {
if (tariff.isBlank() || tariff.length > 100) throw IllegalArgumentException("invalid tariff")
if (cpo.length > 200) throw IllegalArgumentException("invalid cpo")
if (price.isBlank() || price.length > 100) throw IllegalArgumentException("invalid price")
if (poiLink.isBlank() || poiLink.length > 200) throw IllegalArgumentException("invalid poiLink")
}
}
@JsonClass(generateAdapter = true)
@Resource(type = "wrong_price")
class ChargepriceWrongPriceFeedback(
val tariff: String,
val cpo: String,
@Json(name = "displayed_price") val displayedPrice: String,
@Json(name = "actual_price") val actualPrice: String,
@Json(name = "poi_link") val poiLink: String,
notes: String,
email: String,
context: String,
language: String,
) : ChargepriceUserFeedback(notes, email, context, language) {
init {
if (tariff.length > 100) throw IllegalArgumentException("invalid tariff")
if (cpo.length > 200) throw IllegalArgumentException("invalid cpo")
if (displayedPrice.length > 100) throw IllegalArgumentException("invalid displayedPrice")
if (actualPrice.length > 100) throw IllegalArgumentException("invalid actualPrice")
if (poiLink.length > 200) throw IllegalArgumentException("invalid poiLink")
}
}
@JsonClass(generateAdapter = true)
@Resource(type = "missing_vehicle")
class ChargepriceMissingVehicleFeedback(
val brand: String,
val model: String,
notes: String,
email: String,
context: String,
language: String,
) : ChargepriceUserFeedback(notes, email, context, language) {
init {
if (brand.length > 100) throw IllegalArgumentException("invalid brand")
if (model.length > 100) throw IllegalArgumentException("invalid model")
}
}

View File

@@ -1,402 +0,0 @@
package net.vonforst.evmap.auto
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.annotations.ExperimentalCarApi
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.info.Model
import androidx.car.app.model.Action
import androidx.car.app.model.ActionStrip
import androidx.car.app.model.CarIcon
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.Row
import androidx.car.app.model.SectionedItemList
import androidx.car.app.model.Template
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.lifecycleScope
import com.github.erfansn.localeconfigx.currentOrDefaultLocale
import jsonapi.Meta
import jsonapi.Relationship
import jsonapi.Relationships
import jsonapi.ResourceIdentifier
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.vonforst.evmap.R
import net.vonforst.evmap.api.chargeprice.ChargePrice
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
import net.vonforst.evmap.api.chargeprice.ChargepriceChargepointMeta
import net.vonforst.evmap.api.chargeprice.ChargepriceInclude
import net.vonforst.evmap.api.chargeprice.ChargepriceMeta
import net.vonforst.evmap.api.chargeprice.ChargepriceOptions
import net.vonforst.evmap.api.chargeprice.ChargepriceRequest
import net.vonforst.evmap.api.chargeprice.ChargepriceRequestTariffMeta
import net.vonforst.evmap.api.chargeprice.ChargepriceStation
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.api.nameForPlugType
import net.vonforst.evmap.api.stringProvider
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.currency
import net.vonforst.evmap.ui.time
import retrofit2.HttpException
import java.io.IOException
import kotlin.math.roundToInt
@ExperimentalCarApi
class ChargepriceScreen(ctx: CarContext, val session: EVMapSession, val charger: ChargeLocation) :
Screen(ctx) {
private val prefs = PreferenceDataSource(ctx)
private val db = AppDatabase.getInstance(carContext)
private val api by lazy {
ChargepriceApi.create(
carContext.getString(R.string.chargeprice_key),
carContext.getString(R.string.chargeprice_api_url)
)
}
private var prices: List<ChargePrice>? = null
private var meta: ChargepriceChargepointMeta? = null
private var chargepoint: Chargepoint? = null
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
private var errorMessage: String? = null
private val batteryRange = prefs.chargepriceBatteryRangeAndroidAuto
override fun onGetTemplate(): Template {
if (prices == null) loadData()
return ListTemplate.Builder().apply {
setTitle(
carContext.getString(
R.string.chargeprice_battery_range,
batteryRange[0],
batteryRange[1]
) + " · " + carContext.getString(R.string.powered_by_chargeprice)
)
setHeaderAction(Action.BACK)
if (prices == null && errorMessage == null) {
setLoading(true)
} else {
val header = meta?.let { meta ->
chargepoint?.let { chargepoint ->
"${
nameForPlugType(
carContext.stringProvider(),
chargepoint.type
)
} ${chargepoint.formatPower(carContext.currentOrDefaultLocale)} ${
carContext.getString(
R.string.chargeprice_stats,
meta.energy,
time(meta.duration.roundToInt()),
meta.energy / meta.duration * 60
)
}"
}
}
val myTariffs = prefs.chargepriceMyTariffs
val myTariffsAll = prefs.chargepriceMyTariffsAll
val prices = prices?.take(maxRows)
if (prices != null && prices.isNotEmpty() && !myTariffsAll && myTariffs != null) {
val (myPrices, otherPrices) = prices.partition { price -> price.tariffId in myTariffs }
val myPricesList = buildPricesList(myPrices)
val otherPricesList = buildPricesList(otherPrices)
if (myPricesList.items.isNotEmpty() && otherPricesList.items.isNotEmpty()) {
addSectionedList(
SectionedItemList.create(
myPricesList,
(header?.let { it + "\n" } ?: "") +
carContext.getString(R.string.chargeprice_header_my_tariffs)
)
)
addSectionedList(
SectionedItemList.create(
otherPricesList,
carContext.getString(R.string.chargeprice_header_other_tariffs)
)
)
} else {
val list =
if (myPricesList.items.isNotEmpty()) myPricesList else otherPricesList
if (header != null) {
addSectionedList(SectionedItemList.create(list, header))
} else {
setSingleList(list)
}
}
} else {
val list = buildPricesList(prices)
if (header != null && list.items.isNotEmpty()) {
addSectionedList(SectionedItemList.create(list, header))
} else {
setSingleList(list)
}
}
}
setActionStrip(
ActionStrip.Builder().addAction(
Action.Builder().setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_chargeprice
)
).build()
).setOnClickListener {
openUrl(carContext, session.cas, ChargepriceApi.getPoiUrl(charger))
}.build()
).build()
)
}.build()
}
private fun buildPricesList(prices: List<ChargePrice>?): ItemList {
return ItemList.Builder().apply {
setNoItemsMessage(
errorMessage
?: carContext.getString(R.string.chargeprice_no_tariffs_found)
)
prices?.forEach { price ->
addItem(Row.Builder().apply {
setTitle(formatProvider(price))
addText(formatPrice(price))
}.build())
}
}.build()
}
private fun formatProvider(price: ChargePrice): String {
if (!price.tariffName.startsWith(price.provider)) {
return price.provider + " " + price.tariffName
} else {
return price.tariffName
}
}
private fun formatPrice(price: ChargePrice): String {
val amount = price.chargepointPrices.first().price
?: return "${carContext.getString(R.string.chargeprice_price_not_available)} (${price.chargepointPrices.first().noPriceReason})"
val totalPrice = carContext.getString(
R.string.charge_price_format,
amount,
currency(price.currency)
)
val kwhPrice = if (amount > 0f) {
carContext.getString(
if (price.chargepointPrices[0].priceDistribution.isOnlyKwh) {
R.string.charge_price_kwh_format
} else {
R.string.charge_price_average_format
},
amount / meta!!.energy,
currency(price.currency)
)
} else null
val monthlyFees = if (price.totalMonthlyFee > 0 || price.monthlyMinSales > 0) {
price.formatMonthlyFees(carContext)
} else null
var text = totalPrice
if (kwhPrice != null && monthlyFees != null) {
text += " ($kwhPrice, $monthlyFees)"
} else if (kwhPrice != null) {
text += " ($kwhPrice)"
} else if (monthlyFees != null) {
text += " ($monthlyFees)"
}
return text
}
private fun loadData() {
if (supportsCarApiLevel3(carContext)) {
val exec = ContextCompat.getMainExecutor(carContext)
val hardwareMan =
carContext.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
hardwareMan.carInfo.fetchModel(exec) { model ->
loadPrices(model)
}
} else {
loadPrices(null)
}
}
private fun loadPrices(model: Model?) {
val dataAdapter = ChargepriceApi.getDataAdapter(charger)
val manufacturer = getVehicleBrand(model?.manufacturer?.value)
val modelName = getVehicleModel(model?.manufacturer?.value, model?.name?.value)
lifecycleScope.launch {
try {
val car = determineVehicle(manufacturer, modelName)
val cpStation = ChargepriceStation.fromEvmap(charger, car.compatibleEvmapConnectors)
if (cpStation.chargePoints.isEmpty()) {
errorMessage =
carContext.getString(R.string.chargeprice_no_compatible_connectors)
invalidate()
return@launch
}
val result = api.getChargePrices(
ChargepriceRequest(
dataAdapter = dataAdapter,
station = cpStation,
vehicle = car,
options = ChargepriceOptions(
batteryRange = batteryRange.map { it.toDouble() },
providerCustomerTariffs = prefs.chargepriceShowProviderCustomerTariffs,
maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null,
currency = prefs.chargepriceCurrency,
allowUnbalancedLoad = prefs.chargepriceAllowUnbalancedLoad,
showPriceUnavailable = true
),
relationships = if (!prefs.chargepriceMyTariffsAll) {
val myTariffs = prefs.chargepriceMyTariffs ?: emptySet()
Relationships(
"tariffs" to Relationship.ToMany(
myTariffs.map {
ResourceIdentifier(
"tariff",
id = it
)
},
meta = Meta.from(
ChargepriceRequestTariffMeta(ChargepriceInclude.ALWAYS),
ChargepriceApi.moshi
)
)
)
} else null
), ChargepriceApi.getChargepriceLanguage()
)
val myTariffs = prefs.chargepriceMyTariffs
// choose the highest power chargepoint
// (we have already filtered so that only compatible ones are included)
val chargepoint = cpStation.chargePoints.maxByOrNull { it.power }
val index = cpStation.chargePoints.indexOf(chargepoint)
this@ChargepriceScreen.chargepoint =
charger.chargepoints.filter { equivalentPlugTypes(it.type).any { it in car.compatibleEvmapConnectors } }[index]
if (chargepoint == null) {
errorMessage =
carContext.getString(R.string.chargeprice_no_compatible_connectors)
invalidate()
return@launch
}
val metaMapped =
result.meta!!.map(ChargepriceMeta::class.java, ChargepriceApi.moshi)!!
meta = metaMapped.chargePoints.maxByOrNull { it.power }
prices = result.data!!.mapNotNull { cp ->
val filteredPrices =
cp.chargepointPrices.filter {
it.plug == chargepoint.plug && it.power == chargepoint.power
}
if (filteredPrices.isEmpty()) {
null
} else {
cp.copy(
chargepointPrices = filteredPrices
)
}
}
.sortedBy { it.chargepointPrices.first().price ?: Double.MAX_VALUE }
.sortedByDescending {
prefs.chargepriceMyTariffsAll ||
myTariffs != null && it.tariffId in myTariffs
}
invalidate()
} catch (e: IOException) {
withContext(Dispatchers.Main) {
CarToast.makeText(
carContext,
R.string.chargeprice_connection_error,
CarToast.LENGTH_LONG
)
.show()
}
} catch (e: HttpException) {
withContext(Dispatchers.Main) {
CarToast.makeText(
carContext,
R.string.chargeprice_connection_error,
CarToast.LENGTH_LONG
)
.show()
}
} catch (e: NoVehicleSelectedException) {
errorMessage = carContext.getString(R.string.chargeprice_select_car_first)
invalidate()
} catch (e: VehicleUnknownException) {
errorMessage = carContext.getString(
R.string.auto_chargeprice_vehicle_unknown,
manufacturer,
modelName
)
invalidate()
} catch (e: VehicleAmbiguousException) {
errorMessage = carContext.getString(
R.string.auto_chargeprice_vehicle_ambiguous,
manufacturer,
modelName
)
invalidate()
} catch (e: VehicleUnavailableException) {
errorMessage =
carContext.getString(R.string.auto_chargeprice_vehicle_unavailable)
invalidate()
}
}
}
private class NoVehicleSelectedException : Exception()
private class VehicleUnknownException : Exception()
private class VehicleAmbiguousException : Exception()
private class VehicleUnavailableException : Exception()
private suspend fun determineVehicle(
manufacturer: String?,
modelName: String?
): ChargepriceCar {
var vehicles = api.getVehicles().filter {
it.id in prefs.chargepriceMyVehicles
}
if (vehicles.isEmpty()) {
throw NoVehicleSelectedException()
} else if (vehicles.size > 1) {
if (manufacturer != null) {
vehicles = vehicles.filter {
it.brand.lowercase() == getVehicleBrand(manufacturer)?.lowercase()
}
if (vehicles.isEmpty()) {
throw VehicleUnknownException()
} else if (vehicles.size > 1) {
if (modelName != null) {
vehicles = vehicles.filter {
it.name.lowercase().startsWith(modelName.lowercase())
}
if (vehicles.isEmpty()) {
throw VehicleUnknownException()
} else if (vehicles.size > 1) {
throw VehicleAmbiguousException()
}
} else {
throw VehicleAmbiguousException()
}
}
} else {
throw VehicleUnavailableException()
}
}
return vehicles[0]
}
}

View File

@@ -32,7 +32,6 @@ import androidx.core.text.HtmlCompat
import androidx.lifecycle.lifecycleScope
import coil.imageLoader
import coil.request.ImageRequest
import com.github.erfansn.localeconfigx.currentOrDefaultLocale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
@@ -152,22 +151,12 @@ class ChargerDetailScreen(
)
.setTitle(carContext.getString(R.string.auto_prices))
.setOnClickListener {
if (prefs.chargepriceNativeIntegration) {
screenManager.push(
ChargepriceScreen(
carContext,
session,
charger
)
)
} else {
val intent = Intent(
Intent.ACTION_VIEW,
Uri.parse(ChargepriceApi.getPoiUrl(charger))
)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
session.cas.startActivity(intent)
}
val intent = Intent(
Intent.ACTION_VIEW,
Uri.parse(ChargepriceApi.getPoiUrl(charger))
)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
session.cas.startActivity(intent)
}
.build())
}

View File

@@ -1,9 +1,7 @@
package net.vonforst.evmap.auto
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager.NameNotFoundException
import android.hardware.Sensor
import android.hardware.SensorManager
@@ -17,8 +15,6 @@ import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.Action
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.GridItem
import androidx.car.app.model.GridTemplate
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.MessageTemplate
@@ -27,15 +23,12 @@ import androidx.car.app.model.Row
import androidx.car.app.model.SectionedItemList
import androidx.car.app.model.Template
import androidx.car.app.model.Toggle
import androidx.core.content.IntentCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import androidx.core.text.HtmlCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.single
import kotlinx.coroutines.launch
import net.vonforst.evmap.BuildConfig
import net.vonforst.evmap.EXTRA_DONATE
@@ -44,11 +37,6 @@ import net.vonforst.evmap.R
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.availability.tesla.TeslaAuthenticationApi
import net.vonforst.evmap.api.availability.tesla.TeslaOwnerApi
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
import net.vonforst.evmap.api.chargeprice.ChargepriceTariff
import net.vonforst.evmap.currencyDisplayName
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragment
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragmentArgs
import net.vonforst.evmap.getPackageInfoCompat
import net.vonforst.evmap.storage.AppDatabase
@@ -57,8 +45,6 @@ import net.vonforst.evmap.storage.PreferenceDataSource
import okhttp3.OkHttpClient
import java.io.IOException
import java.time.Instant
import kotlin.math.max
import kotlin.math.min
@ExperimentalCarApi
class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
@@ -86,23 +72,6 @@ class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
screenManager.push(DataSettingsScreen(carContext, session))
}
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.settings_chargeprice))
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_chargeprice
)
).setTint(
CarColor.DEFAULT
).build()
)
setBrowsable(true)
setOnClickListener {
screenManager.push(ChargepriceSettingsScreen(carContext))
}
}.build())
if (supportsCarApiLevel3(carContext)) {
addItem(
Row.Builder()
@@ -507,341 +476,6 @@ class ChooseDataSourceScreen(
}
}
class ChargepriceSettingsScreen(ctx: CarContext) : Screen(ctx) {
val prefs = PreferenceDataSource(carContext)
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
override fun onGetTemplate(): Template {
return ListTemplate.Builder().apply {
setTitle(carContext.getString(R.string.settings_chargeprice))
setHeaderAction(Action.BACK)
setSingleList(ItemList.Builder().apply {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_native_integration))
addText(carContext.getString(if (prefs.chargepriceNativeIntegration) R.string.pref_chargeprice_native_integration_on else R.string.pref_chargeprice_native_integration_off))
setToggle(Toggle.Builder {
prefs.chargepriceNativeIntegration = it
invalidate()
}.setChecked(prefs.chargepriceNativeIntegration).build())
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_my_vehicle))
setBrowsable(true)
setOnClickListener {
screenManager.push(SelectVehiclesScreen(carContext))
}
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_my_tariffs))
setBrowsable(true)
setOnClickListener {
screenManager.push(SelectTariffsScreen(carContext))
}
addText(
if (prefs.chargepriceMyTariffsAll) {
carContext.getString(R.string.chargeprice_all_tariffs_selected)
} else {
val n = prefs.chargepriceMyTariffs?.size ?: 0
carContext.resources
.getQuantityString(
R.plurals.chargeprice_some_tariffs_selected,
n,
n
) + "\n" + carContext.resources.getQuantityString(
R.plurals.pref_my_tariffs_summary,
n
)
}
)
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.settings_android_auto_chargeprice_range))
setBrowsable(true)
val range = prefs.chargepriceBatteryRangeAndroidAuto
addText(
carContext.getString(
R.string.chargeprice_battery_range,
range[0],
range[1]
)
)
setOnClickListener {
screenManager.push(SelectChargingRangeScreen(carContext))
}
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_currency))
val values =
carContext.resources.getStringArray(R.array.pref_chargeprice_currencies)
val names = values.map(::currencyDisplayName)
val index = values.indexOf(prefs.chargepriceCurrency)
addText(if (index >= 0) names[index] else "")
setBrowsable(true)
setOnClickListener {
screenManager.push(SelectCurrencyScreen(carContext))
}
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_no_base_fee))
setToggle(Toggle.Builder {
prefs.chargepriceNoBaseFee = it
}.setChecked(prefs.chargepriceNoBaseFee).build())
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
if (maxRows > 6) {
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_show_provider_customer_tariffs))
addText(carContext.getString(R.string.pref_chargeprice_show_provider_customer_tariffs_summary))
setToggle(Toggle.Builder {
prefs.chargepriceShowProviderCustomerTariffs = it
}.setChecked(prefs.chargepriceShowProviderCustomerTariffs).build())
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
addItem(Row.Builder().apply {
setTitle(carContext.getString(R.string.pref_chargeprice_allow_unbalanced_load))
addText(carContext.getString(R.string.pref_chargeprice_allow_unbalanced_load_summary))
setToggle(Toggle.Builder {
prefs.chargepriceAllowUnbalancedLoad = it
}.setChecked(prefs.chargepriceAllowUnbalancedLoad).build())
setEnabled(prefs.chargepriceNativeIntegration)
}.build())
}
}.build())
}.build()
}
}
class SelectVehiclesScreen(ctx: CarContext) : MultiSelectSearchScreen<ChargepriceCar>(ctx) {
private val prefs = PreferenceDataSource(carContext)
private var api = ChargepriceApi.create(
carContext.getString(R.string.chargeprice_key),
carContext.getString(R.string.chargeprice_api_url)
)
override val isMultiSelect = true
override val shouldShowSelectAll = false
override fun isSelected(it: ChargepriceCar): Boolean {
return prefs.chargepriceMyVehicles.contains(it.id)
}
override fun toggleSelected(item: ChargepriceCar) {
if (isSelected(item)) {
prefs.chargepriceMyVehicles = prefs.chargepriceMyVehicles.minus(item.id)
} else {
prefs.chargepriceMyVehicles = prefs.chargepriceMyVehicles.plus(item.id)
}
}
override fun getLabel(it: ChargepriceCar) = "${it.brand} ${it.name}"
override fun getDetails(it: ChargepriceCar) = it.formatSpecs()
override suspend fun loadData(): List<ChargepriceCar> {
return api.getVehicles()
}
}
class SelectTariffsScreen(ctx: CarContext) : MultiSelectSearchScreen<ChargepriceTariff>(ctx) {
private val prefs = PreferenceDataSource(carContext)
private var api = ChargepriceApi.create(
carContext.getString(R.string.chargeprice_key),
carContext.getString(R.string.chargeprice_api_url)
)
override val isMultiSelect = true
override val shouldShowSelectAll = true
override fun isSelected(it: ChargepriceTariff): Boolean {
return prefs.chargepriceMyTariffsAll or (prefs.chargepriceMyTariffs?.contains(it.id)
?: false)
}
override fun toggleSelected(item: ChargepriceTariff) {
val tariffs = prefs.chargepriceMyTariffs ?: if (prefs.chargepriceMyTariffsAll) {
fullList!!.map { it.id }.toSet()
} else {
emptySet()
}
if (isSelected(item)) {
prefs.chargepriceMyTariffs = tariffs.minus(item.id)
prefs.chargepriceMyTariffsAll = false
} else {
prefs.chargepriceMyTariffs = tariffs.plus(item.id)
if (prefs.chargepriceMyTariffs == fullList!!.map { it.id }.toSet()) {
prefs.chargepriceMyTariffsAll = true
}
}
}
override fun selectAll() {
prefs.chargepriceMyTariffsAll = true
super.selectAll()
}
override fun selectNone() {
prefs.chargepriceMyTariffsAll = false
prefs.chargepriceMyTariffs = emptySet()
super.selectNone()
}
override fun getLabel(it: ChargepriceTariff): String {
return if (!it.name.lowercase().startsWith(it.provider.lowercase())) {
"${it.provider} ${it.name}"
} else {
it.name
}
}
override suspend fun loadData(): List<ChargepriceTariff> {
return api.getTariffs()
}
}
class SelectCurrencyScreen(ctx: CarContext) : MultiSelectSearchScreen<Pair<String, String>>(ctx) {
private val prefs = PreferenceDataSource(carContext)
override val isMultiSelect = false
override val shouldShowSelectAll = false
override fun isSelected(it: Pair<String, String>): Boolean =
prefs.chargepriceCurrency == it.second
override fun toggleSelected(item: Pair<String, String>) {
prefs.chargepriceCurrency = item.second
}
override fun getLabel(it: Pair<String, String>): String = it.first
override suspend fun loadData(): List<Pair<String, String>> {
val values = carContext.resources.getStringArray(R.array.pref_chargeprice_currencies)
val names = values.map(::currencyDisplayName)
return names.zip(values)
}
}
class SelectChargingRangeScreen(ctx: CarContext) : Screen(ctx) {
val prefs = PreferenceDataSource(carContext)
private val maxItems = if (ctx.carAppApiLevel >= 2) {
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_GRID)
} else 6
override fun onGetTemplate(): Template {
return GridTemplate.Builder().apply {
setTitle(carContext.getString(R.string.settings_android_auto_chargeprice_range))
setHeaderAction(Action.BACK)
setSingleList(
ItemList.Builder().apply {
addItem(GridItem.Builder().apply {
setTitle(carContext.getString(R.string.chargeprice_battery_range_from))
setText(
carContext.getString(
R.string.percent_format,
prefs.chargepriceBatteryRangeAndroidAuto[0]
)
)
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_add
)
).build()
)
setOnClickListener {
prefs.chargepriceBatteryRangeAndroidAuto =
prefs.chargepriceBatteryRangeAndroidAuto.toMutableList().apply {
this[0] = min(this[1] - 5, this[0] + 5)
}
invalidate()
}
}.build())
addItem(GridItem.Builder().apply {
setTitle(carContext.getString(R.string.chargeprice_battery_range_to))
setText(
carContext.getString(
R.string.percent_format,
prefs.chargepriceBatteryRangeAndroidAuto[1]
)
)
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_add
)
).build()
)
setOnClickListener {
prefs.chargepriceBatteryRangeAndroidAuto =
prefs.chargepriceBatteryRangeAndroidAuto.toMutableList().apply {
this[1] = min(100f, this[1] + 5)
}
invalidate()
}
}.build())
val nSpacers = when {
maxItems % 3 == 0 -> 1
maxItems == 100 -> 0 // AA has increased the limit to 100 and changed the way items are laid out
maxItems % 4 == 0 -> 2
else -> 0
}
for (i in 0..nSpacers) {
addItem(GridItem.Builder().apply {
setTitle(" ")
setImage(emptyCarIcon)
}.build())
}
addItem(GridItem.Builder().apply {
setTitle(" ")
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_remove
)
).build()
)
setOnClickListener {
prefs.chargepriceBatteryRangeAndroidAuto =
prefs.chargepriceBatteryRangeAndroidAuto.toMutableList().apply {
this[0] = max(0f, this[0] - 5)
}
invalidate()
}
}.build())
addItem(GridItem.Builder().apply {
setTitle(" ")
setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
R.drawable.ic_remove
)
).build()
)
setOnClickListener {
prefs.chargepriceBatteryRangeAndroidAuto =
prefs.chargepriceBatteryRangeAndroidAuto.toMutableList().apply {
this[1] = max(this[0] + 5, this[1] - 5)
}
invalidate()
}
}.build())
}.build()
)
}.build()
}
}
@ExperimentalCarApi
class AboutScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
val prefs = PreferenceDataSource(ctx)

View File

@@ -1,275 +0,0 @@
package net.vonforst.evmap.fragment
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.navigation.ui.setupWithNavController
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.transition.MaterialContainerTransform
import net.vonforst.evmap.MapsActivity
import net.vonforst.evmap.R
import net.vonforst.evmap.adapter.ChargepriceAdapter
import net.vonforst.evmap.adapter.CheckableChargepriceCarAdapter
import net.vonforst.evmap.adapter.CheckableConnectorAdapter
import net.vonforst.evmap.adapter.SingleViewAdapter
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.databinding.FragmentChargepriceBinding
import net.vonforst.evmap.databinding.FragmentChargepriceHeaderBinding
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.navigation.safeNavigate
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.viewmodel.ChargepriceViewModel
import net.vonforst.evmap.viewmodel.Status
import net.vonforst.evmap.viewmodel.savedStateViewModelFactory
import java.text.NumberFormat
class ChargepriceFragment : Fragment() {
private lateinit var binding: FragmentChargepriceBinding
private lateinit var headerBinding: FragmentChargepriceHeaderBinding
private var connectionErrorSnackbar: Snackbar? = null
private val vm: ChargepriceViewModel by viewModels(factoryProducer = {
savedStateViewModelFactory { state ->
ChargepriceViewModel(
requireActivity().application,
getString(R.string.chargeprice_key),
getString(R.string.chargeprice_api_url),
state
)
}
})
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
sharedElementEnterTransition = MaterialContainerTransform()
if (savedInstanceState == null) {
val prefs = PreferenceDataSource(requireContext())
prefs.chargepriceCounter += 1
if ((prefs.chargepriceCounter).mod(30) == 0) {
showDonationDialog()
}
}
}
override fun onResume() {
super.onResume()
vm.reloadPrefs()
}
private fun showDonationDialog() {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.chargeprice_donation_dialog_title)
.setMessage(R.string.chargeprice_donation_dialog_detail)
.setNegativeButton(R.string.ok) { di, _ ->
di.cancel()
}
.setPositiveButton(R.string.donate) { di, _ ->
di.dismiss()
findNavController().safeNavigate(ChargepriceFragmentDirections.actionChargepriceToDonateFragment())
}
.show()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = DataBindingUtil.inflate(
inflater,
R.layout.fragment_chargeprice, container, false
)
headerBinding = DataBindingUtil.inflate(
inflater,
R.layout.fragment_chargeprice_header, container, false
)
binding.lifecycleOwner = viewLifecycleOwner
binding.vm = vm
headerBinding.lifecycleOwner = viewLifecycleOwner
headerBinding.vm = vm
binding.toolbar.inflateMenu(R.menu.chargeprice)
binding.toolbar.setTitle(R.string.chargeprice_title)
ViewCompat.setOnApplyWindowInsetsListener(binding.chargePricesList) { v, insets ->
v.updatePadding(bottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom)
WindowInsetsCompat.CONSUMED
}
return binding.root
}
@SuppressLint("ClickableViewAccessibility")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.toolbar.setupWithNavController(
findNavController(),
(requireActivity() as MapsActivity).appBarConfiguration
)
val fragmentArgs: ChargepriceFragmentArgs by navArgs()
val charger = fragmentArgs.charger
vm.charger.value = charger
if (vm.chargepoint.value == null) {
vm.chargepoint.value = charger.chargepointsMerged[0]
}
val vehicleAdapter = CheckableChargepriceCarAdapter()
headerBinding.vehicleSelection.adapter = vehicleAdapter
val vehicleObserver: Observer<ChargepriceCar?> = Observer {
vehicleAdapter.setCheckedItem(it)
}
vm.vehicle.observe(viewLifecycleOwner, vehicleObserver)
vehicleAdapter.onCheckedItemChangedListener = {
vm.vehicle.removeObserver(vehicleObserver)
vm.vehicle.value = it
vm.vehicle.observe(viewLifecycleOwner, vehicleObserver)
}
val chargepriceAdapter = ChargepriceAdapter().apply {
onClickListener = {
(requireActivity() as MapsActivity).openUrl(it.url, binding.root)
}
}
val joinedAdapter = ConcatAdapter(
SingleViewAdapter(headerBinding.root),
chargepriceAdapter
)
binding.chargePricesList.apply {
adapter = joinedAdapter
layoutManager =
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
addItemDecoration(
DividerItemDecoration(
context, LinearLayoutManager.VERTICAL
)
)
}
vm.chargepriceMetaForChargepoint.observe(viewLifecycleOwner) {
chargepriceAdapter.meta = it?.data
}
vm.myTariffs.observe(viewLifecycleOwner) {
chargepriceAdapter.myTariffs = it
}
vm.myTariffsAll.observe(viewLifecycleOwner) {
chargepriceAdapter.myTariffsAll = it
}
vm.chargePricesForChargepoint.observe(viewLifecycleOwner) {
chargepriceAdapter.submitList(it?.data ?: emptyList())
}
val connectorsAdapter = CheckableConnectorAdapter()
val observer: Observer<Chargepoint?> = Observer {
connectorsAdapter.setCheckedItem(it)
}
vm.chargepoint.observe(viewLifecycleOwner, observer)
connectorsAdapter.onCheckedItemChangedListener = {
vm.chargepoint.removeObserver(observer)
vm.chargepoint.value = it
vm.chargepoint.observe(viewLifecycleOwner, observer)
}
vm.vehicleCompatibleConnectors.observe(viewLifecycleOwner) { plugs ->
connectorsAdapter.enabledConnectors =
plugs?.flatMap { plug -> equivalentPlugTypes(plug) }
}
headerBinding.connectorsList.apply {
adapter = connectorsAdapter
layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
}
binding.imgChargepriceLogo.setOnClickListener {
(requireActivity() as MapsActivity).openUrl(
ChargepriceApi.getPoiUrl(charger),
binding.root
)
}
binding.btnSettings.setOnClickListener {
findNavController().safeNavigate(ChargepriceFragmentDirections.actionChargepriceToChargepriceSettingsFragment())
}
headerBinding.batteryRange.setLabelFormatter { value: Float ->
val fmt = NumberFormat.getNumberInstance()
fmt.maximumFractionDigits = 0
fmt.format(value.toDouble()) + "%"
}
headerBinding.batteryRange.setOnTouchListener { _: View, motionEvent: MotionEvent ->
when (motionEvent.actionMasked) {
MotionEvent.ACTION_DOWN -> vm.batteryRangeSliderDragging.value = true
MotionEvent.ACTION_UP -> vm.batteryRangeSliderDragging.value = false
}
false
}
headerBinding.tvChargeFromTo.setOnClickListener {
it.postDelayed({
vm.resetBatteryRangeToDefault()
}, 250)
}
binding.toolbar.setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_help -> {
(activity as? MapsActivity)?.openUrl(
getString(R.string.chargeprice_faq_link),
binding.root
)
true
}
else -> false
}
}
vm.chargePricesForChargepoint.observe(viewLifecycleOwner) { res ->
when (res?.status) {
Status.ERROR -> {
if (vm.vehicle.value == null) return@observe
connectionErrorSnackbar?.dismiss()
connectionErrorSnackbar = Snackbar
.make(
view,
R.string.chargeprice_connection_error,
Snackbar.LENGTH_INDEFINITE
)
.setAction(R.string.retry) {
connectionErrorSnackbar?.dismiss()
vm.loadPrices()
}
connectionErrorSnackbar!!.show()
}
Status.SUCCESS, null -> {
connectionErrorSnackbar?.dismiss()
}
Status.LOADING -> {
}
}
}
// Workaround for AndroidX bug: https://github.com/material-components/material-components-android/issues/1984
view.setBackgroundColor(MaterialColors.getColor(view, android.R.attr.windowBackground))
}
}

View File

@@ -47,7 +47,6 @@ import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.FragmentNavigatorExtras
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.navigation.ui.setupWithNavController
@@ -448,19 +447,10 @@ class MapFragment : Fragment(), OnMapReadyCallback, MenuProvider {
}
binding.detailView.btnChargeprice.setOnClickListener {
val charger = vm.charger.value?.data ?: return@setOnClickListener
if (prefs.chargepriceNativeIntegration) {
val extras =
FragmentNavigatorExtras(binding.detailView.btnChargeprice to getString(R.string.shared_element_chargeprice))
findNavController().safeNavigate(
MapFragmentDirections.actionMapToChargepriceFragment(charger),
extras
)
} else {
(activity as? MapsActivity)?.openUrl(
ChargepriceApi.getPoiUrl(charger),
binding.root
)
}
(activity as? MapsActivity)?.openUrl(
ChargepriceApi.getPoiUrl(charger),
binding.root
)
}
binding.detailView.btnChargerWebsite.setOnClickListener {
val charger = vm.charger.value?.data ?: return@setOnClickListener

View File

@@ -1,43 +0,0 @@
package net.vonforst.evmap.fragment.preference
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import net.vonforst.evmap.R
import net.vonforst.evmap.ui.RangeSliderPreference
import java.text.NumberFormat
class AndroidAutoSettingsFragment : BaseSettingsFragment() {
override val isTopLevel = false
private lateinit var rangePreference: RangeSliderPreference
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
rangePreference = findPreference("chargeprice_battery_range_android_auto")!!
rangePreference.labelFormatter = { value: Float ->
val fmt = NumberFormat.getNumberInstance()
fmt.maximumFractionDigits = 0
fmt.format(value.toDouble()) + "%"
}
updateRangePreferenceSummary()
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings_android_auto, rootKey)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
when (key) {
"chargeprice_battery_range_android_auto_min", "chargeprice_battery_range_android_auto_max" -> {
updateRangePreferenceSummary()
}
}
}
private fun updateRangePreferenceSummary() {
val range = prefs.chargepriceBatteryRangeAndroidAuto
rangePreference.summary = getString(R.string.chargeprice_battery_range, range[0], range[1])
}
}

View File

@@ -1,147 +0,0 @@
package net.vonforst.evmap.fragment.preference
import android.content.SharedPreferences
import android.os.Bundle
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.RelativeSizeSpan
import android.view.View
import androidx.fragment.app.viewModels
import androidx.preference.CheckBoxPreference
import androidx.preference.ListPreference
import net.vonforst.evmap.R
import net.vonforst.evmap.currencyDisplayName
import net.vonforst.evmap.ui.MultiSelectDialogPreference
import net.vonforst.evmap.viewmodel.SettingsViewModel
import net.vonforst.evmap.viewmodel.viewModelFactory
class ChargepriceSettingsFragment : BaseSettingsFragment() {
override val isTopLevel = false
private val vm: SettingsViewModel by viewModels(factoryProducer = {
viewModelFactory {
SettingsViewModel(
requireActivity().application,
getString(R.string.chargeprice_key),
getString(R.string.chargeprice_api_url)
)
}
})
private lateinit var myVehiclePreference: MultiSelectDialogPreference
private lateinit var myTariffsPreference: MultiSelectDialogPreference
private lateinit var nativeIntegrationPreference: CheckBoxPreference
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
nativeIntegrationPreference = findPreference("chargeprice_native_integration")!!
myVehiclePreference = findPreference("chargeprice_my_vehicle")!!
myVehiclePreference.isEnabled = false
vm.vehicles.observe(viewLifecycleOwner) { res ->
res.data?.let { cars ->
val sortedCars = cars.sortedBy { it.brand }
myVehiclePreference.entryValues = sortedCars.map { it.id }.toTypedArray()
myVehiclePreference.entries = sortedCars.map {
SpannableStringBuilder().apply {
appendLine("${it.brand} ${it.name}")
append(
it.formatSpecs(),
RelativeSizeSpan(0.86f),
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
)
}
}.toTypedArray()
myVehiclePreference.isEnabled = nativeIntegrationPreference.isChecked
updateMyVehiclesSummary()
}
}
myTariffsPreference = findPreference("chargeprice_my_tariffs")!!
myTariffsPreference.isEnabled = false
vm.tariffs.observe(viewLifecycleOwner) { res ->
res.data?.let { tariffs ->
myTariffsPreference.entryValues = tariffs.map { it.id }.toTypedArray()
myTariffsPreference.entries = tariffs.map {
if (!it.name.lowercase().startsWith(it.provider.lowercase())) {
"${it.provider} ${it.name}"
} else {
it.name
}
}.toTypedArray()
myTariffsPreference.isEnabled = nativeIntegrationPreference.isChecked
updateMyTariffsSummary()
}
}
updateNativeIntegrationState()
val currencyPreference = findPreference<ListPreference>("chargeprice_currency")!!
currencyPreference.entries = currencyPreference.entryValues.map {
currencyDisplayName(it.toString()).replaceFirstChar { it.uppercase() }
}.toTypedArray()
}
private fun updateNativeIntegrationState() {
for (i in 0 until preferenceScreen.preferenceCount) {
val pref = preferenceScreen.getPreference(i)
if (pref == nativeIntegrationPreference) {
continue
} else if (pref == myTariffsPreference) {
pref.isEnabled =
nativeIntegrationPreference.isChecked && vm.tariffs.value?.data != null
} else if (pref == myVehiclePreference) {
pref.isEnabled =
nativeIntegrationPreference.isChecked && vm.tariffs.value?.data != null
} else {
pref.isEnabled = nativeIntegrationPreference.isChecked
}
}
}
private fun updateMyTariffsSummary() {
myTariffsPreference.summary =
if (prefs.chargepriceMyTariffsAll) {
getString(R.string.chargeprice_all_tariffs_selected)
} else {
val n = prefs.chargepriceMyTariffs?.size ?: 0
requireContext().resources
.getQuantityString(
R.plurals.chargeprice_some_tariffs_selected,
n,
n
) + "\n" + requireContext().resources
.getQuantityString(R.plurals.pref_my_tariffs_summary, n)
}
}
private fun updateMyVehiclesSummary() {
vm.vehicles.value?.data?.let { cars ->
val vehicles = cars.filter { it.id in prefs.chargepriceMyVehicles }
val summary = vehicles.joinToString(", ") {
"${it.brand} ${it.name}"
}
myVehiclePreference.summary = summary
}
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings_chargeprice, rootKey)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
when (key) {
"chargeprice_my_vehicle" -> {
updateMyVehiclesSummary()
}
"chargeprice_my_tariffs" -> {
updateMyTariffsSummary()
}
"chargeprice_native_integration" -> {
updateNativeIntegrationState()
}
}
}
}

View File

@@ -1,10 +1,10 @@
package net.vonforst.evmap.fragment.preference
import android.content.SharedPreferences
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.core.net.toUri
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
@@ -24,7 +24,6 @@ import net.vonforst.evmap.viewmodel.viewModelFactory
import okhttp3.OkHttpClient
import okio.IOException
import java.time.Instant
import androidx.core.net.toUri
class DataSettingsFragment : BaseSettingsFragment() {
override val isTopLevel = false
@@ -33,8 +32,6 @@ class DataSettingsFragment : BaseSettingsFragment() {
viewModelFactory {
SettingsViewModel(
requireActivity().application,
getString(R.string.chargeprice_key),
getString(R.string.chargeprice_api_url)
)
}
})

View File

@@ -152,87 +152,6 @@ class PreferenceDataSource(val context: Context) {
sp.edit().putBoolean("update_0.6.0_androidauto_dialog_shown", value).apply()
}
var chargepriceNativeIntegration: Boolean
get() = sp.getBoolean("chargeprice_native_integration", true)
set(value) {
sp.edit().putBoolean("chargeprice_native_integration", value).apply()
}
var chargepriceMyVehicles: Set<String>
get() = try {
sp.getStringSet("chargeprice_my_vehicle", emptySet())!!
} catch (e: ClassCastException) {
// backwards compatibility
sp.getString("chargeprice_my_vehicle", null)?.let { setOf(it) } ?: emptySet()
}
set(value) {
sp.edit().putStringSet("chargeprice_my_vehicle", value).apply()
}
var chargepriceLastSelectedVehicle: String?
get() = sp.getString("chargeprice_last_vehicle", null)
set(value) {
sp.edit().putString("chargeprice_last_vehicle", value).apply()
}
var chargepriceMyTariffs: Set<String>?
get() = sp.getStringSet("chargeprice_my_tariffs", null)
set(value) {
sp.edit().putStringSet("chargeprice_my_tariffs", value).apply()
}
var chargepriceMyTariffsAll: Boolean
get() = sp.getBoolean("chargeprice_my_tariffs_all", true)
set(value) {
sp.edit().putBoolean("chargeprice_my_tariffs_all", value).apply()
}
var chargepriceNoBaseFee: Boolean
get() = sp.getBoolean("chargeprice_no_base_fee", false)
set(value) {
sp.edit().putBoolean("chargeprice_no_base_fee", value).apply()
}
var chargepriceShowProviderCustomerTariffs: Boolean
get() = sp.getBoolean("chargeprice_show_provider_customer_tariffs", false)
set(value) {
sp.edit().putBoolean("chargeprice_show_provider_customer_tariffs", value).apply()
}
var chargepriceAllowUnbalancedLoad: Boolean
get() = sp.getBoolean("chargeprice_allow_unbalanced_load", false)
set(value) {
sp.edit().putBoolean("chargeprice_allow_unbalanced_load", value).apply()
}
var chargepriceCurrency: String
get() = sp.getString("chargeprice_currency", null) ?: "EUR"
set(value) {
sp.edit().putString("chargeprice_currency", value).apply()
}
var chargepriceBatteryRange: List<Float>
get() = listOf(
sp.getFloat("chargeprice_battery_range_min", 20f),
sp.getFloat("chargeprice_battery_range_max", 80f),
)
set(value) {
sp.edit().putFloat("chargeprice_battery_range_min", value[0])
.putFloat("chargeprice_battery_range_max", value[1])
.apply()
}
var chargepriceBatteryRangeAndroidAuto: List<Float>
get() = listOf(
sp.getFloat("chargeprice_battery_range_android_auto_min", 20f),
sp.getFloat("chargeprice_battery_range_android_auto_max", 80f),
)
set(value) {
sp.edit().putFloat("chargeprice_battery_range_android_auto_min", value[0])
.putFloat("chargeprice_battery_range_android_auto_max", value[1])
.apply()
}
/** App start counter, introduced with Version 1.0.0 */
var appStartCounter: Long
get() = sp.getLong("app_start_counter", 0)

View File

@@ -1,320 +0,0 @@
package net.vonforst.evmap.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.viewModelScope
import jsonapi.Meta
import jsonapi.Relationship
import jsonapi.Relationships
import jsonapi.ResourceIdentifier
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.chargeprice.ChargePrice
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
import net.vonforst.evmap.api.chargeprice.ChargepriceChargepointMeta
import net.vonforst.evmap.api.chargeprice.ChargepriceInclude
import net.vonforst.evmap.api.chargeprice.ChargepriceMeta
import net.vonforst.evmap.api.chargeprice.ChargepriceOptions
import net.vonforst.evmap.api.chargeprice.ChargepriceRequest
import net.vonforst.evmap.api.chargeprice.ChargepriceRequestTariffMeta
import net.vonforst.evmap.api.chargeprice.ChargepriceStation
import net.vonforst.evmap.api.equivalentPlugTypes
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.storage.PreferenceDataSource
import retrofit2.HttpException
import java.io.IOException
class ChargepriceViewModel(
application: Application,
chargepriceApiKey: String,
chargepriceApiUrl: String,
private val state: SavedStateHandle
) :
AndroidViewModel(application) {
private var api = ChargepriceApi.create(chargepriceApiKey, chargepriceApiUrl)
private var prefs = PreferenceDataSource(application)
val charger: MutableLiveData<ChargeLocation> by lazy {
state.getLiveData("charger")
}
val chargepoint: MutableLiveData<Chargepoint?> by lazy {
state.getLiveData("chargepoint")
}
private val vehicleIds: MutableLiveData<Set<String>> by lazy {
MutableLiveData<Set<String>>().apply {
value = prefs.chargepriceMyVehicles
}
}
val vehicles: LiveData<Resource<List<ChargepriceCar>>> by lazy {
MediatorLiveData<Resource<List<ChargepriceCar>>>().apply {
addSource(vehicleIds.distinctUntilChanged()) { vehicleIds ->
if (vehicleIds.isEmpty()) {
value = Resource.success(emptyList())
} else {
value = Resource.loading(null)
viewModelScope.launch {
value = try {
val result = api.getVehicles()
Resource.success(result.filter {
it.id in vehicleIds
})
} catch (e: IOException) {
Resource.error(e.message, null)
} catch (e: HttpException) {
Resource.error(e.message, null)
}
}
}
}
observeForever {
vehicle.value = it.data?.firstOrNull()
}
}
}
val vehicle: MutableLiveData<ChargepriceCar> by lazy {
state.getLiveData("vehicle")
}
val vehicleCompatibleConnectors: LiveData<List<String>> by lazy {
MediatorLiveData<List<String>>().apply {
addSource(vehicle) {
value = it?.compatibleEvmapConnectors
}
}
}
val noCompatibleConnectors: LiveData<Boolean> by lazy {
MediatorLiveData<Boolean>().apply {
value = false
listOf(charger, vehicleCompatibleConnectors).forEach {
addSource(it) {
val charger = charger.value ?: return@addSource
val connectors = vehicleCompatibleConnectors.value ?: return@addSource
value = !charger.chargepoints.flatMap { equivalentPlugTypes(it.type) }
.any { it in connectors }
}
}
}
}
val batteryRange: MutableLiveData<List<Float>> by lazy {
MutableLiveData<List<Float>>().apply {
value = prefs.chargepriceBatteryRange
observeForever {
if (it[0] == it[1]) {
value = if (it[0] < 1.0) {
listOf(it[0], it[1] + 1)
} else {
listOf(it[0] - 1, it[1])
}
}
prefs.chargepriceBatteryRange = value!!
}
}
}
val batteryRangeSliderDragging: MutableLiveData<Boolean> by lazy {
MutableLiveData<Boolean>().apply {
value = false
}
}
val chargePrices: MutableLiveData<Resource<List<ChargePrice>>> by lazy {
MediatorLiveData<Resource<List<ChargePrice>>>().apply {
value = state["chargePrices"] ?: Resource.loading(null)
listOf(
vehicle,
batteryRange,
batteryRangeSliderDragging,
vehicleCompatibleConnectors,
myTariffs, myTariffsAll, charger
).forEach {
addSource(it.distinctUntilChanged()) {
if (!batteryRangeSliderDragging.value!!) {
loadPrices()
state["chargePrices"] = this.value
}
}
}
}
}
val chargePriceMeta: MutableLiveData<Resource<ChargepriceMeta>> by lazy {
MutableLiveData<Resource<ChargepriceMeta>>().apply {
value = Resource.loading(null)
}
}
val chargePricesForChargepoint: MediatorLiveData<Resource<List<ChargePrice>>> by lazy {
MediatorLiveData<Resource<List<ChargePrice>>>().apply {
listOf(chargePrices, chargepoint).forEach {
addSource(it) {
val cps = chargePrices.value
val chargepoint = chargepoint.value
if (cps == null || chargepoint == null) {
value = null
} else if (cps.status == Status.ERROR) {
value = Resource.error(cps.message, null)
} else if (cps.status == Status.LOADING) {
value = Resource.loading(null)
} else {
val myTariffs = prefs.chargepriceMyTariffs
value = Resource.success(cps.data!!.mapNotNull { cp ->
val filteredPrices =
cp.chargepointPrices.filter {
it.plug == getChargepricePlugType(chargepoint) && it.power == chargepoint.power
}
if (filteredPrices.isEmpty()) {
null
} else {
cp.copy(
chargepointPrices = filteredPrices
)
}
}
.sortedBy { it.chargepointPrices.first().price ?: Double.MAX_VALUE }
.sortedByDescending {
prefs.chargepriceMyTariffsAll ||
myTariffs != null && it.tariffId in myTariffs
}
)
}
}
}
}
}
fun reloadPrefs() {
vehicleIds.value = prefs.chargepriceMyVehicles
}
private fun getChargepricePlugType(chargepoint: Chargepoint): String {
val index = charger.value!!.chargepointsMerged.indexOf(chargepoint)
val type = charger.value!!.chargepriceData!!.plugTypes?.get(index) ?: chargepoint.type
return type
}
val myTariffs: LiveData<Set<String>> by lazy {
MutableLiveData<Set<String>>().apply {
value = prefs.chargepriceMyTariffs
}
}
val myTariffsAll: LiveData<Boolean> by lazy {
MutableLiveData<Boolean>().apply {
value = prefs.chargepriceMyTariffsAll
}
}
val chargepriceMetaForChargepoint: MediatorLiveData<Resource<ChargepriceChargepointMeta>> by lazy {
MediatorLiveData<Resource<ChargepriceChargepointMeta>>().apply {
listOf(chargePriceMeta, chargepoint).forEach {
addSource(it) {
val cpMeta = chargePriceMeta.value
val chargepoint = chargepoint.value
if (cpMeta == null || chargepoint == null) {
value = null
} else if (cpMeta.status == Status.ERROR) {
value = Resource.error(cpMeta.message, null)
} else if (cpMeta.status == Status.LOADING) {
value = Resource.loading(null)
} else {
val result = cpMeta.data!!.chargePoints.filter {
it.plug == getChargepricePlugType(
chargepoint
) && it.power == chargepoint.power
}.elementAtOrNull(0)
value = if (result != null) {
Resource.success(result)
} else {
Resource.error("matching chargepoint not found", null)
}
}
}
}
}
}
private var loadPricesJob: Job? = null
fun loadPrices() {
chargePrices.value = Resource.loading(null)
chargePriceMeta.value = Resource.loading(null)
val charger = charger.value
val car = vehicle.value
val compatibleConnectors = vehicleCompatibleConnectors.value
val myTariffs = myTariffs.value
val myTariffsAll = myTariffsAll.value
if (charger == null || car == null || compatibleConnectors == null || myTariffsAll == null || myTariffsAll == false && myTariffs == null) {
chargePrices.value = Resource.error(null, null)
return
}
val cpStation = ChargepriceStation.fromEvmap(charger, compatibleConnectors)
if (cpStation.chargePoints.isEmpty()) {
// no compatible connectors
chargePrices.value = Resource.success(emptyList())
chargePriceMeta.value = Resource.success(ChargepriceMeta(emptyList()))
return
}
loadPricesJob?.cancel()
loadPricesJob = viewModelScope.launch {
try {
val result = api.getChargePrices(
ChargepriceRequest(
dataAdapter = ChargepriceApi.getDataAdapter(charger),
station = cpStation,
vehicle = car,
options = ChargepriceOptions(
batteryRange = batteryRange.value!!.map { it.toDouble() },
providerCustomerTariffs = prefs.chargepriceShowProviderCustomerTariffs,
maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null,
currency = prefs.chargepriceCurrency,
allowUnbalancedLoad = prefs.chargepriceAllowUnbalancedLoad,
showPriceUnavailable = true
),
relationships = if (!myTariffsAll) {
Relationships(
"tariffs" to Relationship.ToMany(
(myTariffs ?: emptySet()).map {
ResourceIdentifier(
"tariff",
id = it
)
},
meta = Meta.from(
ChargepriceRequestTariffMeta(ChargepriceInclude.ALWAYS),
ChargepriceApi.moshi
)
)
)
} else null
), ChargepriceApi.getChargepriceLanguage()
)
val meta = result.meta!!.map(ChargepriceMeta::class.java, ChargepriceApi.moshi)!!
chargePrices.value = Resource.success(result.data)
chargePriceMeta.value = Resource.success(meta)
} catch (e: IOException) {
chargePrices.value = Resource.error(e.message, null)
chargePriceMeta.value = Resource.error(e.message, null)
} catch (e: HttpException) {
chargePrices.value = Resource.error(e.message, null)
chargePriceMeta.value = Resource.error(e.message, null)
}
}
}
fun resetBatteryRangeToDefault() {
batteryRange.value = prefs.chargepriceBatteryRangeAndroidAuto
}
}

View File

@@ -6,38 +6,16 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
import net.vonforst.evmap.api.chargeprice.ChargepriceTariff
import net.vonforst.evmap.storage.AppDatabase
import net.vonforst.evmap.storage.PreferenceDataSource
import retrofit2.HttpException
import java.io.IOException
class SettingsViewModel(
application: Application,
chargepriceApiKey: String,
chargepriceApiUrl: String
) :
AndroidViewModel(application) {
private val api = ChargepriceApi.create(chargepriceApiKey, chargepriceApiUrl)
private val db = AppDatabase.getInstance(application)
private val prefs = PreferenceDataSource(application)
val vehicles: MutableLiveData<Resource<List<ChargepriceCar>>> by lazy {
MutableLiveData<Resource<List<ChargepriceCar>>>().apply {
value = Resource.loading(null)
loadVehicles()
}
}
val tariffs: MutableLiveData<Resource<List<ChargepriceTariff>>> by lazy {
MutableLiveData<Resource<List<ChargepriceTariff>>>().apply {
value = Resource.loading(null)
loadTariffs()
}
}
val chargerCacheCount: LiveData<Long> by lazy {
db.chargeLocationsDao().getCount()
}
@@ -52,32 +30,6 @@ class SettingsViewModel(
}
}
private fun loadVehicles() {
viewModelScope.launch {
try {
val result = api.getVehicles()
vehicles.value = Resource.success(result)
} catch (e: IOException) {
vehicles.value = Resource.error(e.message, null)
} catch (e: HttpException) {
vehicles.value = Resource.error(e.message, null)
}
}
}
private fun loadTariffs() {
viewModelScope.launch {
try {
val result = api.getTariffs()
tariffs.value = Resource.success(result)
} catch (e: IOException) {
tariffs.value = Resource.error(e.message, null)
} catch (e: HttpException) {
tariffs.value = Resource.error(e.message, null)
}
}
}
fun deleteRecentSearchResults() {
viewModelScope.launch {
db.recentAutocompletePlaceDao().deleteAll()

View File

@@ -543,7 +543,6 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/go_to_chargeprice"
android:transitionName="@string/shared_element_chargeprice"
app:goneUnless="@{charger.data != null &amp;&amp; ChargepriceApi.isChargerSupported(charger.data)}"
app:icon="@drawable/ic_chargeprice" />

View File

@@ -1,141 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="net.vonforst.evmap.viewmodel.ChargepriceViewModel" />
<import type="net.vonforst.evmap.viewmodel.Status" />
<import type="net.vonforst.evmap.ui.BindingAdaptersKt" />
<variable
name="vm"
type="ChargepriceViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/linearLayout5"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:transitionName="@string/shared_element_chargeprice">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/toolbar_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:liftOnScroll="true">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?android:actionBarSize">
<ImageView
android:id="@+id/imgChargepriceLogo"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="?selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/powered_by_chargeprice"
android:focusable="true"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:layout_gravity="right"
app:srcCompat="@drawable/ic_powered_by_chargeprice"
app:tint="?android:textColorPrimary"
tools:ignore="RtlSymmetry" />
</com.google.android.material.appbar.MaterialToolbar>
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/charge_prices_list"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar_container"
tools:itemCount="1"
tools:listitem="@layout/fragment_chargeprice_preview" />
<ProgressBar
android:id="@+id/progressBar5"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="280dp"
app:goneUnless="@{vm.chargePricesForChargepoint.status == Status.LOADING || vm.vehicles.status == Status.LOADING}"
app:layout_constraintBottom_toBottomOf="@+id/charge_prices_list"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/charge_prices_list"
app:layout_constraintTop_toTopOf="@+id/charge_prices_list" />
<TextView
android:id="@+id/textView8"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginTop="280dp"
android:gravity="center_horizontal"
android:text="@string/chargeprice_no_tariffs_found"
app:goneUnless="@{vm.chargePricesForChargepoint.status == Status.SUCCESS &amp;&amp; vm.chargePricesForChargepoint.data.size() == 0}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/charge_prices_list"
app:layout_constraintTop_toTopOf="@+id/charge_prices_list" />
<TextView
android:id="@+id/textView9"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginTop="280dp"
android:gravity="center_horizontal"
android:text="@string/chargeprice_no_compatible_connectors"
app:goneUnless="@{vm.noCompatibleConnectors}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/charge_prices_list"
app:layout_constraintTop_toTopOf="@+id/charge_prices_list" />
<TextView
android:id="@+id/textView3"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="280dp"
android:layout_marginEnd="16dp"
android:gravity="center_horizontal"
android:text="@string/chargeprice_select_car_first"
app:goneUnless="@{vm.vehicles.status == Status.SUCCESS &amp;&amp; vm.vehicles.data.size() == 0}"
app:layout_constraintBottom_toTopOf="@+id/btnSettings"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/charge_prices_list"
app:layout_constraintTop_toTopOf="@+id/charge_prices_list"
app:layout_constraintVertical_chainStyle="packed" />
<Button
android:id="@+id/btnSettings"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/settings"
app:goneUnless="@{vm.vehicles.status == Status.SUCCESS &amp;&amp; vm.vehicles.data.size() == 0}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView3" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@@ -1,122 +0,0 @@
<?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.ChargepriceViewModel" />
<import type="net.vonforst.evmap.viewmodel.Status" />
<import type="net.vonforst.evmap.ui.BindingAdaptersKt" />
<variable
name="vm"
type="ChargepriceViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp">
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:text="@string/chargeprice_select_connector"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorPrimary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/vehicle_selection" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/connectors_list"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:data="@{vm.charger.chargepointsMerged}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView"
tools:itemCount="3"
tools:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_connector_button"
tools:orientation="horizontal" />
<TextView
android:id="@+id/tvChargeFromTo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:clickable="true"
android:focusable="true"
android:background="?selectableItemBackground"
android:text="@{String.format(@string/chargeprice_battery_range, vm.batteryRange[0], vm.batteryRange[1])}"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorPrimary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/connectors_list"
tools:text="Charge from 20% to 80%" />
<TextView
android:id="@+id/textView4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{@string/chargeprice_stats(vm.chargepriceMetaForChargepoint.data.energy, BindingAdaptersKt.time((int) Math.round(vm.chargepriceMetaForChargepoint.data.duration)), vm.chargepriceMetaForChargepoint.data.energy / vm.chargepriceMetaForChargepoint.data.duration * 60)}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:invisibleUnlessAnimated="@{!vm.batteryRangeSliderDragging &amp;&amp; vm.chargepriceMetaForChargepoint.status == Status.SUCCESS}"
app:layout_constraintStart_toStartOf="@+id/tvChargeFromTo"
app:layout_constraintTop_toBottomOf="@+id/tvChargeFromTo"
tools:text="(18 kWh, approx. 23 min, ⌀ 50 kW)" />
<TextView
android:id="@+id/tvVehicleHeader"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:text="@string/chargeprice_vehicle"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorPrimary"
app:goneUnless="@{vm.vehicles.data != null &amp;&amp; vm.vehicles.data.size() > 1}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/vehicle_selection"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvVehicleHeader"
app:data="@{vm.vehicles.data}"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:goneUnless="@{vm.vehicles.data != null &amp;&amp; vm.vehicles.data.size() > 1}"
android:orientation="horizontal"
tools:listitem="@layout/item_chargeprice_vehicle_chip" />
<com.google.android.material.slider.RangeSlider
android:id="@+id/battery_range"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:valueFrom="0.0"
android:valueTo="100.0"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView4"
app:values="@={vm.batteryRange}" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<include layout="@layout/fragment_chargeprice_header" />
<include layout="@layout/item_chargeprice" />
<include layout="@layout/item_chargeprice" />
<include layout="@layout/item_chargeprice" />
<include layout="@layout/item_chargeprice" />
<include layout="@layout/item_chargeprice" />
</LinearLayout>

View File

@@ -1,173 +0,0 @@
<?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.api.chargeprice.ChargePrice" />
<import type="net.vonforst.evmap.api.chargeprice.ChargepriceChargepointMeta" />
<import type="net.vonforst.evmap.ui.BindingAdaptersKt" />
<import type="java.util.Set" />
<variable
name="item"
type="ChargePrice" />
<variable
name="meta"
type="ChargepriceChargepointMeta" />
<variable
name="myTariffs"
type="Set&lt;String>" />
<variable
name="myTariffsAll"
type="Boolean" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="16dp"
android:paddingTop="8dp"
android:paddingRight="16dp"
android:paddingBottom="8dp"
android:background="@{BindingAdaptersKt.tariffBackground(context,!myTariffsAll &amp;&amp; myTariffs.contains(item.tariffId), item.branding.backgroundColor)}">
<TextView
android:id="@+id/txtTariff"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@{item.tariffName}"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
app:layout_constraintBottom_toTopOf="@+id/txtProvider"
app:layout_constraintEnd_toStartOf="@+id/ivLogo"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="CheapCharge" />
<TextView
android:id="@+id/txtProvider"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@{item.provider}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:goneUnless="@{!item.tariffName.toLowerCase().startsWith(item.provider.toLowerCase())}"
app:layout_constraintBottom_toTopOf="@+id/rvTags"
app:layout_constraintEnd_toStartOf="@+id/ivLogo"
app:layout_constraintStart_toStartOf="@+id/txtTariff"
app:layout_constraintTop_toBottomOf="@+id/txtTariff"
tools:text="Cheap Charging Co." />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvTags"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false"
app:data="@{item.tags}"
app:layout_constraintBottom_toTopOf="@+id/txtProviderCustomerTariff"
app:layout_constraintEnd_toStartOf="@+id/ivLogo"
app:layout_constraintStart_toStartOf="@+id/txtTariff"
app:layout_constraintTop_toBottomOf="@+id/txtProvider"
tools:itemCount="1"
tools:listitem="@layout/item_chargeprice_tag" />
<TextView
android:id="@+id/txtProviderCustomerTariff"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/chargeprice_provider_customer_tariff"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:goneUnless="@{item.providerCustomerTariff}"
app:layout_constraintBottom_toTopOf="@id/txtMonthlyFee"
app:layout_constraintEnd_toStartOf="@+id/ivLogo"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="@+id/txtTariff"
app:layout_constraintTop_toBottomOf="@+id/rvTags" />
<TextView
android:id="@+id/txtMonthlyFee"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@{item.formatMonthlyFees(context)}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:goneUnless="@{item.totalMonthlyFee > 0 || item.monthlyMinSales > 0}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/ivLogo"
app:layout_constraintStart_toStartOf="@+id/txtTariff"
app:layout_constraintTop_toBottomOf="@+id/txtProviderCustomerTariff"
tools:text="Base fee 1 €/month" />
<TextView
android:id="@+id/txtPrice"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:gravity="end"
android:text="@{item.chargepointPrices.get(0).price != null ? String.format(@string/charge_price_format, item.chargepointPrices.get(0).price, BindingAdaptersKt.currency(item.currency)) : @string/chargeprice_price_not_available}"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintBottom_toTopOf="@+id/txtAveragePrice"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/guideline5"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="1,50 €" />
<TextView
android:id="@+id/txtAveragePrice"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:gravity="end"
android:text="@{item.chargepointPrices.get(0).price != null ? String.format(item.chargepointPrices.get(0).priceDistribution.isOnlyKwh ? @string/charge_price_kwh_format : @string/charge_price_average_format, item.chargepointPrices.get(0).price / meta.energy, BindingAdaptersKt.currency(item.currency)) : item.chargepointPrices.get(0).noPriceReason}"
app:goneUnless="@{item.chargepointPrices.get(0).price > 0 || item.chargepointPrices.get(0).price == null}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:layout_constraintBottom_toTopOf="@id/txtPriceDetails"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/guideline5"
app:layout_constraintTop_toBottomOf="@+id/txtPrice"
tools:text="⌀ 0,29 €/kWh" />
<TextView
android:id="@+id/txtPriceDetails"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:gravity="end"
android:text="@{item.chargepointPrices.get(0).formatDistribution(context)}"
app:goneUnless="@{!item.chargepointPrices.get(0).formatDistribution(context).empty}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/guideline5"
app:layout_constraintTop_toBottomOf="@+id/txtAveragePrice"
tools:text="pro kWh + ab 4h Blockiergeb." />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.65" />
<ImageView
android:id="@+id/ivLogo"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_margin="8dp"
android:scaleType="fitCenter"
app:invisibleUnless="@{item.branding.logoUrl != null}"
app:imageUrl="@{item.branding.logoUrl}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/guideline5"
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@tools:sample/avatars" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@@ -1,40 +0,0 @@
<?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.api.chargeprice.ChargepriceTag" />
<variable
name="item"
type="ChargepriceTag" />
</data>
<net.vonforst.evmap.ui.BalancedBreakingTextView
android:id="@+id/rvTags"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:background="@drawable/rounded_rect_16dp"
android:maxLines="3"
android:text="@{item.text}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?android:textColorPrimary"
android:theme="@style/ThemeOverlay.Material3.Dark"
android:gravity="center_vertical"
android:drawablePadding="4dp"
android:paddingEnd="8dp"
android:paddingStart="3dp"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:drawableTint="?android:textColorPrimary"
android:breakStrategy="balanced"
app:chargepriceTagColor="@{item.kind}"
app:chargepriceTagIcon="@{item.kind}"
tools:backgroundTint="@color/chargeprice_alert"
tools:drawableLeft="@drawable/ic_chargeprice_alert"
tools:text="Only for drivers of blue cars" />
</layout>

View File

@@ -1,27 +0,0 @@
<?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.api.chargeprice.ChargepriceCar" />
<variable
name="item"
type="ChargepriceCar" />
<variable
name="selectedItem"
type="ChargepriceCar" />
</data>
<com.google.android.material.chip.Chip
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="4dp"
android:layout_marginRight="4dp"
android:text="@{item.brand + ' ' + item.name}"
android:checked="@{item == selectedItem}"
tools:text="Tesla Model 2" />
</layout>

View File

@@ -27,9 +27,6 @@
app:enterAnim="@animator/nav_default_enter_anim"
app:popEnterAnim="@animator/nav_default_pop_enter_anim"
app:popExitAnim="@animator/nav_default_pop_exit_anim" />
<action
android:id="@+id/action_map_to_chargepriceFragment"
app:destination="@id/chargeprice" />
<action
android:id="@+id/action_map_to_opensource_donations"
app:destination="@id/opensource_donations" />
@@ -88,16 +85,6 @@
app:argType="boolean"
android:defaultValue="false" />
</fragment>
<fragment
android:id="@+id/settings_chargeprice"
android:name="net.vonforst.evmap.fragment.preference.ChargepriceSettingsFragment"
android:label="@string/settings_chargeprice"
tools:layout="@layout/fragment_preference" />
<fragment
android:id="@+id/settings_android_auto"
android:name="net.vonforst.evmap.fragment.preference.AndroidAutoSettingsFragment"
android:label="@string/settings_android_auto"
tools:layout="@layout/fragment_preference" />
<fragment
android:id="@+id/settings_developer"
android:name="net.vonforst.evmap.fragment.preference.DeveloperSettingsFragment"
@@ -126,25 +113,6 @@
android:name="net.vonforst.evmap.fragment.FilterProfilesFragment"
android:label="@string/menu_manage_filter_profiles"
tools:layout="@layout/fragment_filter_profiles" />
<fragment
android:id="@+id/chargeprice"
android:name="net.vonforst.evmap.fragment.ChargepriceFragment"
android:label="@string/chargeprice_title"
tools:layout="@layout/fragment_chargeprice">
<action
android:id="@+id/action_chargeprice_to_chargepriceSettingsFragment"
app:destination="@id/settings_chargeprice"
app:exitAnim="@animator/nav_default_exit_anim"
app:enterAnim="@animator/nav_default_enter_anim"
app:popEnterAnim="@animator/nav_default_enter_anim"
app:popExitAnim="@animator/nav_default_exit_anim" />
<action
android:id="@+id/action_chargeprice_to_donateFragment"
app:destination="@id/donate" />
<argument
android:name="charger"
app:argType="net.vonforst.evmap.model.ChargeLocation" />
</fragment>
<fragment
android:id="@+id/donate"
android:name="net.vonforst.evmap.fragment.DonateFragment"

View File

@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="shared_element_picture">picture</string>
<string name="shared_element_chargeprice">chargeprice</string>
<string name="github_link">https://github.com/ev-map/EVMap</string>
<string name="twitter_handle">\@ev_map</string>
<string name="twitter_url">https://twitter.com/ev_map</string>
@@ -10,7 +9,6 @@
<string name="goingelectric_forum_url"><![CDATA[https://www.goingelectric.de/forum/viewtopic.php?f=5&t=56342]]></string>
<string name="tff_forum_url"><![CDATA[https://tff-forum.de/t/283834]]></string>
<string name="github_sponsors_link">https://github.com/sponsors/johan12345/</string>
<string name="chargeprice_api_url">https://api.chargeprice.app/v1/</string>
<string name="fronyx_url">https://fronyx.io/</string>
<string name="website_url">https://ev-map.app</string>
<string name="about_contributors_list">

View File

@@ -9,14 +9,6 @@
android:fragment="net.vonforst.evmap.fragment.preference.DataSettingsFragment"
android:title="@string/settings_data_sources"
android:icon="@drawable/ic_settings_data_source" />
<Preference
android:fragment="net.vonforst.evmap.fragment.preference.ChargepriceSettingsFragment"
android:title="@string/settings_chargeprice"
android:icon="@drawable/ic_chargeprice" />
<Preference
android:fragment="net.vonforst.evmap.fragment.preference.AndroidAutoSettingsFragment"
android:title="@string/settings_android_auto"
android:icon="@drawable/ic_android_auto" />
<Preference
android:key="developer_options"
android:fragment="net.vonforst.evmap.fragment.preference.DeveloperSettingsFragment"

View File

@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto">
<net.vonforst.evmap.ui.RangeSliderPreference
android:key="chargeprice_battery_range_android_auto"
android:title="@string/settings_android_auto_chargeprice_range"
android:valueFrom="0.0"
android:valueTo="100.0"
app:updatesContinuously="true"
android:defaultValue="20.0,80.0"
android:layout="@layout/preference_widget_rangeslider"
tools:summary="@string/chargeprice_battery_range" />
</PreferenceScreen>

View File

@@ -1,41 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<CheckBoxPreference
android:key="chargeprice_native_integration"
android:title="@string/pref_chargeprice_native_integration"
android:summaryOn="@string/pref_chargeprice_native_integration_on"
android:summaryOff="@string/pref_chargeprice_native_integration_off"
app:defaultValue="true" />
<net.vonforst.evmap.ui.MultiSelectDialogPreference
android:key="chargeprice_my_vehicle"
android:title="@string/pref_my_vehicle"
app:showAllButton="false"
app:defaultToAll="false" />
<net.vonforst.evmap.ui.MultiSelectDialogPreference
android:key="chargeprice_my_tariffs"
android:title="@string/pref_my_tariffs" />
<ListPreference
android:key="chargeprice_currency"
android:title="@string/pref_chargeprice_currency"
android:entryValues="@array/pref_chargeprice_currencies"
android:defaultValue="EUR"
app:useSimpleSummaryProvider="true" />
<CheckBoxPreference
android:key="chargeprice_no_base_fee"
android:title="@string/pref_chargeprice_no_base_fee"
android:defaultValue="false"
app:singleLineTitle="false" />
<CheckBoxPreference
android:key="chargeprice_show_provider_customer_tariffs"
android:title="@string/pref_chargeprice_show_provider_customer_tariffs"
android:summary="@string/pref_chargeprice_show_provider_customer_tariffs_summary"
android:defaultValue="false"
app:singleLineTitle="false" />
<CheckBoxPreference
android:key="chargeprice_allow_unbalanced_load"
android:title="@string/pref_chargeprice_allow_unbalanced_load"
android:summary="@string/pref_chargeprice_allow_unbalanced_load_summary"
android:defaultValue="false"
app:singleLineTitle="false" />
</PreferenceScreen>