mirror of
https://github.com/ev-map/EVMap.git
synced 2025-12-28 09:37:44 -05:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aa1fcf5bd | ||
|
|
221e5f49bc | ||
|
|
df6f26ad56 | ||
|
|
1210efd3b9 | ||
|
|
097be8c92b | ||
|
|
16031884ac | ||
|
|
c0b4c56eda | ||
|
|
9587ee948d | ||
|
|
890eec4419 | ||
|
|
c972c871d4 | ||
|
|
e4da902430 | ||
|
|
7a5d4b4107 | ||
|
|
80642b1731 | ||
|
|
6dab611c1b | ||
|
|
d9fc43af68 | ||
|
|
2fd0fa7e22 | ||
|
|
b04284fb16 | ||
|
|
7b3bd84d18 | ||
|
|
773d052819 | ||
|
|
4e0ad98e17 | ||
|
|
d8e572338a | ||
|
|
ff86eeff95 | ||
|
|
47f57992fb | ||
|
|
0ae59358ca | ||
|
|
576e0b9c42 | ||
|
|
3878b27154 | ||
|
|
2166ac076a | ||
|
|
c489df2aaf | ||
|
|
56712ff1af | ||
|
|
e2cf332f34 | ||
|
|
0b541d498d | ||
|
|
1bdc576300 | ||
|
|
fb5da76834 | ||
|
|
ad922f0667 | ||
|
|
773b35d9ce | ||
|
|
a3347c9d62 | ||
|
|
da671b8dd3 | ||
|
|
6d877e13e4 | ||
|
|
8ab1d7170c | ||
|
|
1f75d722cd | ||
|
|
11bd4b2cec | ||
|
|
dcc03da237 | ||
|
|
295c00ea55 | ||
|
|
8d6756d57d | ||
|
|
71acd28b74 | ||
|
|
e79c1168ff | ||
|
|
9833159fa8 | ||
|
|
88ace5ba82 | ||
|
|
0ed82d15ff | ||
|
|
0f525a6c48 | ||
|
|
a91a5ce52e | ||
|
|
cd3b1db90d | ||
|
|
6e3e34c642 | ||
|
|
8ce7f5cae2 | ||
|
|
fae3bb2038 | ||
|
|
9490aa7110 | ||
|
|
66a27d19f3 | ||
|
|
09cf6cb087 | ||
|
|
4d23c916a9 | ||
|
|
fec5de1de1 | ||
|
|
89957ef738 | ||
|
|
a8e9bcd9eb | ||
|
|
0c3e3b0c35 | ||
|
|
78f9b7162c | ||
|
|
600a294ab2 | ||
|
|
1b8bedcd6d | ||
|
|
1b7b5121e6 |
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -31,6 +31,7 @@ jobs:
|
||||
CHARGEPRICE_API_KEY: ${{ secrets.CHARGEPRICE_API_KEY }}
|
||||
MAPBOX_API_KEY: ${{ secrets.MAPBOX_API_KEY }}
|
||||
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
|
||||
FRONYX_API_KEY: ${{ secrets.FRONYX_API_KEY }}
|
||||
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
KEYSTORE_ALIAS: ${{ secrets.KEYSTORE_ALIAS }}
|
||||
KEYSTORE_ALIAS_PASSWORD: ${{ secrets.KEYSTORE_ALIAS_PASSWORD }}
|
||||
|
||||
@@ -8,7 +8,6 @@ apply plugin: 'kotlin-parcelize'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'androidx.navigation.safeargs.kotlin'
|
||||
apply plugin: 'com.mikepenz.aboutlibraries.plugin'
|
||||
apply plugin: 'de.timfreiheit.resourceplaceholders'
|
||||
|
||||
def supportedLocales = "en,de,fr,nb-rNO"
|
||||
|
||||
@@ -19,13 +18,13 @@ android {
|
||||
defaultConfig {
|
||||
applicationId "net.vonforst.evmap"
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 31
|
||||
targetSdkVersion 33
|
||||
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
|
||||
versionCode 138
|
||||
versionName "1.4.0"
|
||||
versionCode 160
|
||||
versionName "1.4.6"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
resConfigs supportedLocales.split(",")
|
||||
resConfigs supportedLocales.split(',')
|
||||
buildConfigField("String", "supportedLocales", '"' + supportedLocales + '"')
|
||||
}
|
||||
|
||||
@@ -104,9 +103,6 @@ android {
|
||||
unitTests.includeAndroidResources true
|
||||
}
|
||||
|
||||
resourcePlaceholders {
|
||||
files = ['xml/shortcuts.xml']
|
||||
}
|
||||
namespace 'net.vonforst.evmap'
|
||||
|
||||
// add API keys from environment variable if not set in apikeys.xml
|
||||
@@ -159,19 +155,19 @@ configurations {
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
implementation 'androidx.appcompat:appcompat:1.6.0-rc01'
|
||||
implementation 'androidx.core:core-ktx:1.8.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.6.0'
|
||||
implementation 'androidx.core:core-ktx:1.9.0'
|
||||
implementation 'androidx.core:core-splashscreen:1.0.0'
|
||||
implementation "androidx.activity:activity-ktx:1.5.1"
|
||||
implementation "androidx.fragment:fragment-ktx:1.5.2"
|
||||
implementation "androidx.activity:activity-ktx:1.6.1"
|
||||
implementation "androidx.fragment:fragment-ktx:1.5.5"
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.0'
|
||||
implementation 'com.google.android.material:material:1.6.1'
|
||||
implementation 'com.google.android.material:material:1.8.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
||||
implementation 'androidx.browser:browser:1.4.0'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'com.github.johan12345:CustomBottomSheetBehavior:8e3de307f2'
|
||||
implementation 'com.github.johan12345:CustomBottomSheetBehavior:f4f641aab5'
|
||||
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
|
||||
implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.9.0'
|
||||
@@ -190,13 +186,13 @@ dependencies {
|
||||
implementation 'com.github.romandanylyk:PageIndicatorView:b1bad589b5'
|
||||
|
||||
// Android Auto
|
||||
def carAppVersion = '1.3.0-beta01'
|
||||
def carAppVersion = '1.3.0-rc01'
|
||||
googleImplementation "androidx.car.app:app:$carAppVersion"
|
||||
googleNormalImplementation "androidx.car.app:app-projected:$carAppVersion"
|
||||
googleAutomotiveImplementation "androidx.car.app:app-automotive:$carAppVersion"
|
||||
|
||||
// AnyMaps
|
||||
def anyMapsVersion = 'a9b3dd7d99'
|
||||
def anyMapsVersion = '7fdcf50fc4'
|
||||
implementation "com.github.johan12345.AnyMaps:anymaps-base:$anyMapsVersion"
|
||||
googleImplementation "com.github.johan12345.AnyMaps:anymaps-google:$anyMapsVersion"
|
||||
googleImplementation 'com.google.android.gms:play-services-maps:18.1.0'
|
||||
@@ -210,8 +206,8 @@ dependencies {
|
||||
implementation 'com.github.johan12345:mapbox-events-android:a21c324501'
|
||||
|
||||
// Google Places
|
||||
googleImplementation 'com.google.android.libraries.places:places:2.6.0'
|
||||
googleImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.6.1'
|
||||
googleImplementation 'com.google.android.libraries.places:places:3.0.0'
|
||||
googleImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.6.4'
|
||||
|
||||
// Mapbox Geocoding
|
||||
implementation 'com.mapbox.mapboxsdk:mapbox-sdk-services:5.5.0'
|
||||
@@ -226,13 +222,13 @@ dependencies {
|
||||
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
|
||||
|
||||
// room library
|
||||
def room_version = "2.4.3"
|
||||
def room_version = "2.5.0"
|
||||
implementation "androidx.room:room-runtime:$room_version"
|
||||
kapt "androidx.room:room-compiler:$room_version"
|
||||
implementation "androidx.room:room-ktx:$room_version"
|
||||
|
||||
// billing library
|
||||
def billing_version = "4.1.0"
|
||||
def billing_version = "5.1.0"
|
||||
googleImplementation "com.android.billingclient:billing:$billing_version"
|
||||
googleImplementation "com.android.billingclient:billing-ktx:$billing_version"
|
||||
|
||||
@@ -254,15 +250,15 @@ dependencies {
|
||||
|
||||
// testing for car app
|
||||
testGoogleImplementation "androidx.car.app:app-testing:$carAppVersion"
|
||||
testGoogleImplementation 'org.robolectric:robolectric:4.8.1'
|
||||
testGoogleImplementation 'androidx.test:core:1.4.0'
|
||||
testGoogleImplementation 'org.robolectric:robolectric:4.9'
|
||||
testGoogleImplementation 'androidx.test:core:1.5.0'
|
||||
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
|
||||
kapt "com.squareup.moshi:moshi-kotlin-codegen:1.13.0"
|
||||
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.2.2'
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.0'
|
||||
}
|
||||
|
||||
private static String decode(String s, String key) {
|
||||
|
||||
@@ -2,5 +2,4 @@
|
||||
<resources>
|
||||
<string name="pref_search_provider_default" translatable="false">mapbox</string>
|
||||
<string name="pref_map_provider_default" translatable="false">mapbox</string>
|
||||
<string name="paypal_link" translatable="false">https://paypal.me/johan98</string>
|
||||
</resources>
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
<queries>
|
||||
<package android:name="com.google.android.projection.gearhead" />
|
||||
<package android:name="com.google.android.apps.automotive.templates.host" />
|
||||
</queries>
|
||||
|
||||
<application>
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.browser.customtabs.CustomTabColorSchemeParams
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.CarToast
|
||||
import androidx.car.app.Screen
|
||||
@@ -22,7 +17,6 @@ import jsonapi.ResourceIdentifier
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.api.chargeprice.*
|
||||
import net.vonforst.evmap.api.equivalentPlugTypes
|
||||
@@ -49,9 +43,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
|
||||
private var prices: List<ChargePrice>? = null
|
||||
private var meta: ChargepriceChargepointMeta? = null
|
||||
private var chargepoint: Chargepoint? = null
|
||||
private val maxRows = if (ctx.carAppApiLevel >= 2) {
|
||||
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
|
||||
} else 6
|
||||
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
|
||||
private var errorMessage: String? = null
|
||||
private val batteryRange = prefs.chargepriceBatteryRangeAndroidAuto
|
||||
|
||||
@@ -77,34 +69,54 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
|
||||
carContext.stringProvider(),
|
||||
chargepoint.type
|
||||
)
|
||||
} ${chargepoint.formatPower()} " + carContext.getString(
|
||||
R.string.chargeprice_stats,
|
||||
meta.energy,
|
||||
time(meta.duration.roundToInt()),
|
||||
meta.energy / meta.duration * 60
|
||||
)
|
||||
} ${chargepoint.formatPower()} ${
|
||||
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 list = ItemList.Builder().apply {
|
||||
setNoItemsMessage(
|
||||
errorMessage ?: carContext.getString(R.string.chargeprice_no_tariffs_found)
|
||||
)
|
||||
prices?.take(maxRows)?.forEach { price ->
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(formatProvider(price))
|
||||
addText(formatPrice(price))
|
||||
if (carContext.carAppApiLevel >= 5) {
|
||||
setEnabled(myTariffsAll || myTariffs != null && price.tariffId in myTariffs)
|
||||
}
|
||||
}.build())
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}.build()
|
||||
if (header != null && list.items.isNotEmpty()) {
|
||||
addSectionedList(SectionedItemList.create(list, header))
|
||||
} else {
|
||||
setSingleList(list)
|
||||
val list = buildPricesList(prices)
|
||||
if (header != null && list.items.isNotEmpty()) {
|
||||
addSectionedList(SectionedItemList.create(list, header))
|
||||
} else {
|
||||
setSingleList(list)
|
||||
}
|
||||
}
|
||||
}
|
||||
setActionStrip(
|
||||
@@ -117,44 +129,28 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
|
||||
)
|
||||
).build()
|
||||
).setOnClickListener {
|
||||
val intent = CustomTabsIntent.Builder()
|
||||
.setDefaultColorSchemeParams(
|
||||
CustomTabColorSchemeParams.Builder()
|
||||
.setToolbarColor(
|
||||
ContextCompat.getColor(
|
||||
carContext,
|
||||
R.color.colorPrimary
|
||||
)
|
||||
)
|
||||
.build()
|
||||
)
|
||||
.build().intent
|
||||
intent.data =
|
||||
Uri.parse(ChargepriceApi.getPoiUrl(charger))
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
try {
|
||||
carContext.startActivity(intent)
|
||||
if (BuildConfig.FLAVOR_automotive != "automotive") {
|
||||
// only show the toast "opened on phone" if we're running on a phone
|
||||
CarToast.makeText(
|
||||
carContext,
|
||||
R.string.opened_on_phone,
|
||||
CarToast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
CarToast.makeText(
|
||||
carContext,
|
||||
R.string.no_browser_app_found,
|
||||
CarToast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
openUrl(carContext, 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
|
||||
@@ -164,19 +160,21 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
|
||||
}
|
||||
|
||||
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,
|
||||
price.chargepointPrices.first().price,
|
||||
amount,
|
||||
currency(price.currency)
|
||||
)
|
||||
val kwhPrice = if (price.chargepointPrices.first().price > 0f) {
|
||||
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
|
||||
},
|
||||
price.chargepointPrices.get(0).price / meta!!.energy,
|
||||
amount / meta!!.energy,
|
||||
currency(price.currency)
|
||||
)
|
||||
} else null
|
||||
@@ -208,7 +206,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
|
||||
}
|
||||
|
||||
private fun loadPrices(model: Model?) {
|
||||
val dataAdapter = ChargepriceApi.getDataAdapter(charger) ?: return
|
||||
val dataAdapter = ChargepriceApi.getDataAdapter(charger)
|
||||
val manufacturer = model?.manufacturer?.value
|
||||
val modelName = getVehicleModel(model?.manufacturer?.value, model?.name?.value)
|
||||
lifecycleScope.launch {
|
||||
@@ -233,7 +231,8 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
|
||||
providerCustomerTariffs = prefs.chargepriceShowProviderCustomerTariffs,
|
||||
maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null,
|
||||
currency = prefs.chargepriceCurrency,
|
||||
allowUnbalancedLoad = prefs.chargepriceAllowUnbalancedLoad
|
||||
allowUnbalancedLoad = prefs.chargepriceAllowUnbalancedLoad,
|
||||
showPriceUnavailable = true
|
||||
),
|
||||
relationships = if (!prefs.chargepriceMyTariffsAll) {
|
||||
val myTariffs = prefs.chargepriceMyTariffs ?: emptySet()
|
||||
@@ -289,7 +288,7 @@ class ChargepriceScreen(ctx: CarContext, val charger: ChargeLocation) : Screen(c
|
||||
)
|
||||
}
|
||||
}.filterNotNull()
|
||||
.sortedBy { it.chargepointPrices.first().price }
|
||||
.sortedBy { it.chargepointPrices.first().price ?: Double.MAX_VALUE }
|
||||
.sortedByDescending {
|
||||
prefs.chargepriceMyTariffsAll ||
|
||||
myTariffs != null && it.tariffId in myTariffs
|
||||
|
||||
@@ -64,9 +64,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
private val iconGen =
|
||||
ChargerIconGenerator(carContext, null, height = imageSize)
|
||||
|
||||
private val maxRows = if (ctx.carAppApiLevel >= 2) {
|
||||
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PANE)
|
||||
} else 2
|
||||
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PANE)
|
||||
private val largeImageSupported =
|
||||
ctx.carAppApiLevel >= 4 // since API 4, Row.setImage is supported
|
||||
|
||||
@@ -350,23 +348,31 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
private fun generateChargepointsText(charger: ChargeLocation): SpannableStringBuilder {
|
||||
val chargepointsText = SpannableStringBuilder()
|
||||
charger.chargepointsMerged.forEachIndexed { i, cp ->
|
||||
if (i > 0) chargepointsText.append(" · ")
|
||||
chargepointsText.append(
|
||||
"${cp.count}× "
|
||||
).append(
|
||||
nameForPlugType(carContext.stringProvider(), cp.type),
|
||||
CarIconSpan.create(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
iconForPlugType(cp.type)
|
||||
)
|
||||
).setTint(
|
||||
CarColor.createCustom(Color.WHITE, Color.BLACK)
|
||||
).build()
|
||||
),
|
||||
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
|
||||
).append(" ").append(cp.formatPower())
|
||||
chargepointsText.apply {
|
||||
if (i > 0) append(" · ")
|
||||
append("${cp.count}× ")
|
||||
val plugIcon = iconForPlugType(cp.type)
|
||||
if (plugIcon != 0) {
|
||||
append(
|
||||
nameForPlugType(carContext.stringProvider(), cp.type),
|
||||
CarIconSpan.create(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
plugIcon
|
||||
)
|
||||
).setTint(
|
||||
CarColor.createCustom(Color.WHITE, Color.BLACK)
|
||||
).build()
|
||||
),
|
||||
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
} else {
|
||||
append(nameForPlugType(carContext.stringProvider(), cp.type))
|
||||
}
|
||||
append(" ")
|
||||
append(cp.formatPower())
|
||||
}
|
||||
availability?.status?.get(cp)?.let { status ->
|
||||
chargepointsText.append(
|
||||
" (${availabilityText(status)}/${cp.count})",
|
||||
@@ -412,6 +418,8 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
val response = repo.getChargepointDetail(chargerSparse.id).awaitFinished()
|
||||
if (response.status == Status.SUCCESS) {
|
||||
val charger = response.data!!
|
||||
this@ChargerDetailScreen.charger = charger
|
||||
invalidate()
|
||||
|
||||
val photo = charger.photos?.firstOrNull()
|
||||
photo?.let {
|
||||
@@ -454,7 +462,8 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
)
|
||||
this@ChargerDetailScreen.photo = outImg
|
||||
}
|
||||
this@ChargerDetailScreen.charger = charger
|
||||
|
||||
invalidate()
|
||||
|
||||
availability = getAvailability(charger).data
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import androidx.car.app.CarContext
|
||||
@@ -11,6 +13,7 @@ import androidx.car.app.model.*
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.map
|
||||
import kotlinx.coroutines.launch
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.model.*
|
||||
@@ -24,15 +27,22 @@ import kotlin.math.roundToInt
|
||||
class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
|
||||
private val prefs = PreferenceDataSource(ctx)
|
||||
private val db = AppDatabase.getInstance(ctx)
|
||||
val filterProfiles: LiveData<List<FilterProfile>> by lazy {
|
||||
private val filterProfiles: LiveData<List<FilterProfile>> by lazy {
|
||||
db.filterProfileDao().getProfiles(prefs.dataSource)
|
||||
}
|
||||
private val maxRows = if (ctx.carAppApiLevel >= 2) {
|
||||
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
|
||||
} else 6
|
||||
|
||||
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
|
||||
private val supportsRefresh = ctx.isAppDrivenRefreshSupported
|
||||
private var page = 0
|
||||
|
||||
init {
|
||||
filterProfiles.observe(this) {
|
||||
val filterStatus = prefs.filterStatus
|
||||
if (filterStatus in listOf(FILTERS_DISABLED, FILTERS_FAVORITES, FILTERS_CUSTOM)) {
|
||||
page = 0
|
||||
} else {
|
||||
page = paginateProfiles(it).indexOfFirst { it.any { it.id == filterStatus } }
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
@@ -40,10 +50,24 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
|
||||
override fun onGetTemplate(): Template {
|
||||
val filterStatus = prefs.filterStatus
|
||||
return ListTemplate.Builder().apply {
|
||||
var title = carContext.getString(R.string.menu_filter)
|
||||
|
||||
filterProfiles.value?.let {
|
||||
setSingleList(buildFilterProfilesList(it, filterStatus))
|
||||
val paginatedProfiles = paginateProfiles(it)
|
||||
setSingleList(buildFilterProfilesList(paginatedProfiles, filterStatus))
|
||||
|
||||
val numPages = paginatedProfiles.size
|
||||
if (numPages > 1) {
|
||||
title += " " + carContext.getString(
|
||||
R.string.auto_multipage,
|
||||
page + 1,
|
||||
numPages
|
||||
)
|
||||
}
|
||||
} ?: setLoading(true)
|
||||
setTitle(carContext.getString(R.string.menu_filter))
|
||||
|
||||
setTitle(title)
|
||||
|
||||
setHeaderAction(Action.BACK)
|
||||
setActionStrip(
|
||||
ActionStrip.Builder().apply {
|
||||
@@ -55,7 +79,6 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
|
||||
R.drawable.ic_edit
|
||||
)
|
||||
).build()
|
||||
|
||||
)
|
||||
setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
lifecycleScope.launch {
|
||||
@@ -70,47 +93,148 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
|
||||
}.build()
|
||||
}
|
||||
|
||||
private fun paginateProfiles(filterProfiles: List<FilterProfile>): List<List<FilterProfile>> {
|
||||
val filterStatus = prefs.filterStatus
|
||||
val extraRows = if (FILTERS_CUSTOM == filterStatus) 3 else 2
|
||||
return filterProfiles.paginate(
|
||||
maxRows - extraRows,
|
||||
maxRows - extraRows - 1,
|
||||
maxRows - 2,
|
||||
maxRows - 1
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildFilterProfilesList(
|
||||
profiles: List<FilterProfile>,
|
||||
paginatedProfiles: List<List<FilterProfile>>,
|
||||
filterStatus: Long
|
||||
): ItemList {
|
||||
val extraRows = if (FILTERS_CUSTOM == filterStatus) 3 else 2
|
||||
val profilesToShow =
|
||||
profiles.sortedByDescending { it.id == filterStatus }.take(maxRows - extraRows)
|
||||
return ItemList.Builder().apply {
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.no_filters))
|
||||
}.build())
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.filter_favorites))
|
||||
}.build())
|
||||
profilesToShow.forEach {
|
||||
if (page > 0) {
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(
|
||||
CarText.Builder(
|
||||
carContext.getString(R.string.auto_multipage_goto, page)
|
||||
).build()
|
||||
)
|
||||
setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_arrow_back
|
||||
)
|
||||
).build(),
|
||||
Row.IMAGE_TYPE_ICON
|
||||
)
|
||||
setOnClickListener {
|
||||
page -= 1
|
||||
if (!supportsRefresh) {
|
||||
screenManager.pushForResult(DummyReturnScreen(carContext)) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
}.build())
|
||||
}
|
||||
|
||||
if (page == 0) {
|
||||
addItem(Row.Builder().apply {
|
||||
val active = filterStatus == FILTERS_DISABLED
|
||||
setTitle(carContext.getString(R.string.no_filters))
|
||||
setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_close
|
||||
)
|
||||
).setTint(if (active) CarColor.SECONDARY else CarColor.DEFAULT)
|
||||
.build(),
|
||||
Row.IMAGE_TYPE_ICON
|
||||
)
|
||||
setOnClickListener { onItemClick(FILTERS_DISABLED) }
|
||||
}.build())
|
||||
addItem(Row.Builder().apply {
|
||||
val active = filterStatus == FILTERS_FAVORITES
|
||||
setTitle(carContext.getString(R.string.filter_favorites))
|
||||
setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_fav
|
||||
)
|
||||
).setTint(if (active) CarColor.SECONDARY else CarColor.DEFAULT)
|
||||
.build(),
|
||||
Row.IMAGE_TYPE_ICON
|
||||
)
|
||||
setOnClickListener { onItemClick(FILTERS_FAVORITES) }
|
||||
}.build())
|
||||
if (FILTERS_CUSTOM == filterStatus) {
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.filter_custom))
|
||||
setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_checkbox_checked
|
||||
)
|
||||
).setTint(CarColor.PRIMARY).build(),
|
||||
Row.IMAGE_TYPE_ICON
|
||||
)
|
||||
setOnClickListener { onItemClick(FILTERS_CUSTOM) }
|
||||
}.build())
|
||||
}
|
||||
}
|
||||
paginatedProfiles[page].forEach {
|
||||
addItem(Row.Builder().apply {
|
||||
val name =
|
||||
it.name.ifEmpty { carContext.getString(R.string.unnamed_filter_profile) }
|
||||
val active = filterStatus == it.id
|
||||
setTitle(name)
|
||||
setImage(
|
||||
if (active)
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_check
|
||||
)
|
||||
).setTint(CarColor.SECONDARY).build() else emptyCarIcon,
|
||||
Row.IMAGE_TYPE_ICON
|
||||
)
|
||||
setOnClickListener { onItemClick(it.id) }
|
||||
}.build())
|
||||
}
|
||||
if (FILTERS_CUSTOM == filterStatus) {
|
||||
if (page < paginatedProfiles.size - 1) {
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.filter_custom))
|
||||
}.build())
|
||||
}
|
||||
setSelectedIndex(when (filterStatus) {
|
||||
FILTERS_DISABLED -> 0
|
||||
FILTERS_FAVORITES -> 1
|
||||
FILTERS_CUSTOM -> profilesToShow.size + 2
|
||||
else -> profilesToShow.indexOfFirst { it.id == filterStatus } + 2
|
||||
})
|
||||
setOnSelectedListener { index ->
|
||||
onItemClick(
|
||||
when (index) {
|
||||
0 -> FILTERS_DISABLED
|
||||
1 -> FILTERS_FAVORITES
|
||||
profilesToShow.size + 2 -> FILTERS_CUSTOM
|
||||
else -> profilesToShow[index - 2].id
|
||||
setTitle(
|
||||
CarText.Builder(
|
||||
carContext.getString(R.string.auto_multipage_goto, page + 2)
|
||||
).build()
|
||||
)
|
||||
setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_arrow_forward
|
||||
)
|
||||
).build(),
|
||||
Row.IMAGE_TYPE_ICON
|
||||
)
|
||||
setOnClickListener {
|
||||
page += 1
|
||||
if (!supportsRefresh) {
|
||||
screenManager.pushForResult(DummyReturnScreen(carContext)) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
)
|
||||
}.build())
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
@@ -125,12 +249,16 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
|
||||
class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
|
||||
private val vm = FilterViewModel(carContext.applicationContext as Application)
|
||||
|
||||
private val maxRows = if (ctx.carAppApiLevel >= 2) {
|
||||
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
|
||||
} else 6
|
||||
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
|
||||
private val supportsRefresh = ctx.isAppDrivenRefreshSupported
|
||||
|
||||
private var page = 0
|
||||
private var paginatedFilters = vm.filtersWithValue.map {
|
||||
it?.paginate(maxRows, maxRows - 1, maxRows - 2, maxRows - 1)
|
||||
}
|
||||
|
||||
init {
|
||||
vm.filtersWithValue.observe(this) {
|
||||
paginatedFilters.observe(this) {
|
||||
vm.filterProfile.observe(this) {
|
||||
invalidate()
|
||||
}
|
||||
@@ -141,18 +269,28 @@ class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
|
||||
val currentProfileName = vm.filterProfile.value?.name
|
||||
|
||||
return ListTemplate.Builder().apply {
|
||||
vm.filtersWithValue.value?.let { filtersWithValue ->
|
||||
setSingleList(buildFiltersList(filtersWithValue.take(maxRows)))
|
||||
paginatedFilters.value?.let { paginatedFilters ->
|
||||
setSingleList(buildFiltersList(paginatedFilters))
|
||||
} ?: setLoading(true)
|
||||
|
||||
setTitle(currentProfileName?.let {
|
||||
var title = currentProfileName?.let {
|
||||
carContext.getString(
|
||||
R.string.edit_filter_profile,
|
||||
it
|
||||
it,
|
||||
)
|
||||
} ?: carContext.getString(R.string.menu_filter))
|
||||
} ?: carContext.getString(R.string.menu_filter)
|
||||
val numPages = paginatedFilters.value?.size ?: 0
|
||||
if (numPages > 1) {
|
||||
title += " " + carContext.getString(
|
||||
R.string.auto_multipage,
|
||||
page + 1,
|
||||
numPages
|
||||
)
|
||||
}
|
||||
setTitle(title)
|
||||
|
||||
setHeaderAction(Action.BACK)
|
||||
|
||||
setActionStrip(ActionStrip.Builder().apply {
|
||||
val currentProfile = vm.filterProfile.value
|
||||
if (currentProfile != null) {
|
||||
@@ -194,29 +332,65 @@ class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
|
||||
).build()
|
||||
)
|
||||
.setOnClickListener {
|
||||
val textPromptScreen = TextPromptScreen(
|
||||
carContext,
|
||||
R.string.save_as_profile,
|
||||
R.string.save_profile_enter_name,
|
||||
currentProfileName
|
||||
)
|
||||
screenManager.pushForResult(textPromptScreen) { name ->
|
||||
if (name == null) return@pushForResult
|
||||
lifecycleScope.launch {
|
||||
vm.saveAsProfile(name as String)
|
||||
screenManager.popTo(MapScreen.MARKER)
|
||||
val textPromptScreen = TextPromptScreen(
|
||||
carContext,
|
||||
R.string.save_as_profile,
|
||||
R.string.save_profile_enter_name,
|
||||
currentProfileName
|
||||
)
|
||||
screenManager.pushForResult(textPromptScreen) { name ->
|
||||
if (name == null) return@pushForResult
|
||||
var saveSuccess = false
|
||||
lifecycleScope.launch {
|
||||
saveSuccess = vm.saveAsProfile(name as String)
|
||||
screenManager.popTo(MapScreen.MARKER)
|
||||
}
|
||||
if (!saveSuccess) return@pushForResult
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
.build()
|
||||
.build()
|
||||
)
|
||||
}.build())
|
||||
}
|
||||
.build())
|
||||
}.build()
|
||||
}
|
||||
|
||||
private fun buildFiltersList(filters: List<FilterWithValue<out FilterValue>>): ItemList {
|
||||
private fun buildFiltersList(paginatedFilters: List<FilterValues>): ItemList {
|
||||
|
||||
return ItemList.Builder().apply {
|
||||
filters.forEach {
|
||||
if (page > 0) {
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(
|
||||
CarText.Builder(
|
||||
carContext.getString(R.string.auto_multipage_goto, page)
|
||||
).build()
|
||||
)
|
||||
setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_arrow_back
|
||||
)
|
||||
).build(),
|
||||
Row.IMAGE_TYPE_ICON
|
||||
)
|
||||
setOnClickListener {
|
||||
page -= 1
|
||||
if (!supportsRefresh) {
|
||||
screenManager.pushForResult(DummyReturnScreen(carContext)) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
}.build())
|
||||
}
|
||||
|
||||
paginatedFilters[page].forEach {
|
||||
val filter = it.filter
|
||||
val value = it.value
|
||||
addItem(Row.Builder().apply {
|
||||
@@ -270,6 +444,37 @@ class EditFiltersScreen(ctx: CarContext) : Screen(ctx) {
|
||||
}
|
||||
}.build())
|
||||
}
|
||||
|
||||
if (page < paginatedFilters.size - 1) {
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(
|
||||
CarText.Builder(
|
||||
carContext.getString(R.string.auto_multipage_goto, page + 2)
|
||||
).build()
|
||||
)
|
||||
setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_arrow_forward
|
||||
)
|
||||
).build(),
|
||||
Row.IMAGE_TYPE_ICON
|
||||
)
|
||||
setOnClickListener {
|
||||
page += 1
|
||||
if (!supportsRefresh) {
|
||||
screenManager.pushForResult(DummyReturnScreen(carContext)) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
}.build())
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
|
||||
private var location: Location? = null
|
||||
private var lastDistanceUpdateTime: Instant? = null
|
||||
private var lastChargersUpdateTime: Instant? = null
|
||||
private var chargers: List<ChargeLocation>? = null
|
||||
private var loadingError = false
|
||||
private var locationError = false
|
||||
@@ -81,14 +82,13 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
private val searchRadius = 5 // kilometers
|
||||
private val distanceUpdateThreshold = Duration.ofSeconds(15)
|
||||
private val availabilityUpdateThreshold = Duration.ofMinutes(1)
|
||||
private val chargersUpdateThresholdDistance = 500 // meters
|
||||
private val chargersUpdateThresholdTime = Duration.ofSeconds(30)
|
||||
private var availabilities: MutableMap<Long, Pair<ZonedDateTime, ChargeLocationStatus?>> =
|
||||
HashMap()
|
||||
private val maxRows = if (ctx.carAppApiLevel >= 2) {
|
||||
min(
|
||||
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST),
|
||||
25
|
||||
)
|
||||
} else 6
|
||||
private val maxRows =
|
||||
min(ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST), 25)
|
||||
private val supportsRefresh = ctx.isAppDrivenRefreshSupported
|
||||
|
||||
private var filterStatus = prefs.filterStatus
|
||||
private var filtersWithValue: List<FilterWithValue<FilterValue>>? = null
|
||||
@@ -205,7 +205,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
).setTint(CarColor.DEFAULT).build()
|
||||
)
|
||||
.setOnClickListener {
|
||||
screenManager.push(SettingsScreen(carContext))
|
||||
screenManager.push(SettingsScreen(carContext, session))
|
||||
session.mapScreen = null
|
||||
}
|
||||
.build())
|
||||
@@ -223,11 +223,16 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
).build()
|
||||
|
||||
)
|
||||
setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
setOnClickListener {
|
||||
if (prefs.placeSearchResultAndroidAuto != null) {
|
||||
prefs.placeSearchResultAndroidAutoName = null
|
||||
prefs.placeSearchResultAndroidAuto = null
|
||||
screenManager.pushForResult(DummyReturnScreen(carContext)) {
|
||||
if (!supportsRefresh) {
|
||||
screenManager.pushForResult(DummyReturnScreen(carContext)) {
|
||||
chargers = null
|
||||
loadChargers()
|
||||
}
|
||||
} else {
|
||||
chargers = null
|
||||
loadChargers()
|
||||
}
|
||||
@@ -243,7 +248,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
}
|
||||
session.mapScreen = null
|
||||
}
|
||||
})
|
||||
}
|
||||
}.build())
|
||||
.addAction(
|
||||
Action.Builder()
|
||||
@@ -263,7 +268,9 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
}
|
||||
.build())
|
||||
.build())
|
||||
if (carContext.carAppApiLevel >= 5) {
|
||||
if (carContext.carAppApiLevel >= 5 ||
|
||||
(BuildConfig.FLAVOR_automotive == "automotive" && carContext.carAppApiLevel >= 4)
|
||||
) {
|
||||
setOnContentRefreshListener(this@MapScreen)
|
||||
}
|
||||
}.build()
|
||||
@@ -328,7 +335,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
availabilities[charger.id]?.second?.let { av ->
|
||||
val status = av.status.values.flatten()
|
||||
val available = availabilityText(status)
|
||||
val total = charger.chargepoints.sumBy { it.count }
|
||||
val total = charger.chargepoints.sumOf { it.count }
|
||||
|
||||
if (text.isNotEmpty()) text.append(" · ")
|
||||
text.append(
|
||||
@@ -362,6 +369,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
this.location = location
|
||||
if (previousLocation == null) {
|
||||
loadChargers()
|
||||
return
|
||||
}
|
||||
|
||||
val now = Instant.now()
|
||||
@@ -372,6 +380,23 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
// update displayed distances
|
||||
invalidate()
|
||||
}
|
||||
|
||||
// if chargers are searched around current location, consider app-driven refresh
|
||||
val searchLocation =
|
||||
if (prefs.placeSearchResultAndroidAuto == null) searchLocation else null
|
||||
val distance = searchLocation?.let {
|
||||
distanceBetween(
|
||||
it.latitude, it.longitude, location.latitude, location.longitude
|
||||
)
|
||||
} ?: 0.0
|
||||
if (supportsRefresh && (lastChargersUpdateTime == null ||
|
||||
Duration.between(
|
||||
lastChargersUpdateTime,
|
||||
now
|
||||
) > chargersUpdateThresholdTime) && (distance > chargersUpdateThresholdDistance)
|
||||
) {
|
||||
onContentRefreshRequested()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadChargers() {
|
||||
@@ -411,13 +436,17 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
).awaitFinished()
|
||||
if (response.status == Status.ERROR) {
|
||||
loadingError = true
|
||||
this@MapScreen.chargers = null
|
||||
invalidate()
|
||||
return@launch
|
||||
}
|
||||
chargers = headingFilter(
|
||||
response.data?.filterIsInstance(ChargeLocation::class.java),
|
||||
searchLocation
|
||||
)
|
||||
chargers = response.data?.filterIsInstance(ChargeLocation::class.java)
|
||||
if (prefs.placeSearchResultAndroidAutoName == null) {
|
||||
chargers = headingFilter(
|
||||
chargers,
|
||||
searchLocation
|
||||
)
|
||||
}
|
||||
if (chargers == null || chargers.size >= maxRows) {
|
||||
break
|
||||
}
|
||||
@@ -426,6 +455,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
}
|
||||
|
||||
updateCoroutine = null
|
||||
lastChargersUpdateTime = Instant.now()
|
||||
lastDistanceUpdateTime = Instant.now()
|
||||
invalidate()
|
||||
} catch (e: IOException) {
|
||||
@@ -441,8 +471,12 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
private fun headingFilter(
|
||||
chargers: List<ChargeLocation>?,
|
||||
searchLocation: LatLng
|
||||
): List<ChargeLocation>? =
|
||||
heading?.orientations?.value?.get(0)?.let { heading ->
|
||||
): List<ChargeLocation>? {
|
||||
// use compass heading if available, otherwise fall back to GPS
|
||||
val location = location
|
||||
val heading = heading?.orientations?.value?.get(0)
|
||||
?: if (location?.hasBearing() == true) location.bearing else null
|
||||
return heading?.let {
|
||||
if (!prefs.showChargersAheadAndroidAuto) return@let chargers
|
||||
|
||||
chargers?.filter {
|
||||
@@ -456,6 +490,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession) :
|
||||
abs(diff) < 30
|
||||
}
|
||||
} ?: chargers
|
||||
}
|
||||
|
||||
private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) {
|
||||
val isUpdate = this.energyLevel == null
|
||||
|
||||
@@ -46,7 +46,7 @@ class PermissionScreen(
|
||||
}
|
||||
|
||||
private fun requestPermissions() {
|
||||
carContext.requestPermissions(permissions) { granted, rejected ->
|
||||
carContext.requestPermissions(permissions) { granted, _ ->
|
||||
if (granted.containsAll(permissions)) {
|
||||
screenManager.pop()
|
||||
} else {
|
||||
|
||||
@@ -105,15 +105,15 @@ class PlaceSearchScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
addText(text)
|
||||
}
|
||||
|
||||
setOnClickListener {
|
||||
lifecycleScope.launch {
|
||||
val placeDetails = getDetails(place.id)
|
||||
prefs.placeSearchResultAndroidAuto = placeDetails.latLng
|
||||
prefs.placeSearchResultAndroidAutoName =
|
||||
place.primaryText.toString()
|
||||
screenManager.popTo(MapScreen.MARKER)
|
||||
}
|
||||
setOnClickListener {
|
||||
lifecycleScope.launch {
|
||||
val placeDetails = getDetails(place.id)
|
||||
prefs.placeSearchResultAndroidAuto = placeDetails.latLng
|
||||
prefs.placeSearchResultAndroidAutoName =
|
||||
place.primaryText.toString()
|
||||
screenManager.popTo(MapScreen.MARKER)
|
||||
}
|
||||
}
|
||||
}.build())
|
||||
@@ -148,6 +148,7 @@ class PlaceSearchScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
|
||||
}
|
||||
|
||||
private suspend fun loadNewList(query: String) {
|
||||
val location = location?.let { LatLng.fromLocation(it) }
|
||||
for (provider in providers) {
|
||||
try {
|
||||
recentResults.clear()
|
||||
@@ -161,7 +162,7 @@ class PlaceSearchScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
|
||||
}
|
||||
recentResults.addAll(recentPlaces)
|
||||
resultList =
|
||||
recentPlaces.map { it.asAutocompletePlace(LatLng.fromLocation(location)) }
|
||||
recentPlaces.map { it.asAutocompletePlace(location) }
|
||||
invalidate()
|
||||
|
||||
// if we already have enough results or the query is short, stop here
|
||||
@@ -170,7 +171,7 @@ class PlaceSearchScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx
|
||||
// then search online
|
||||
val recentIds = recentPlaces.map { it.id }
|
||||
resultList = withContext(Dispatchers.IO) {
|
||||
(resultList!! + provider.autocomplete(query, LatLng.fromLocation(location))
|
||||
(resultList!! + provider.autocomplete(query, location)
|
||||
.filter { !recentIds.contains(it.id) }).take(maxItems)
|
||||
}
|
||||
invalidate()
|
||||
|
||||
@@ -15,9 +15,7 @@ abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
|
||||
protected var fullList: List<T>? = null
|
||||
private var currentList: List<T> = emptyList()
|
||||
private var query: String = ""
|
||||
private val maxRows = if (ctx.carAppApiLevel >= 2) {
|
||||
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
|
||||
} else 6
|
||||
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
|
||||
protected abstract val isMultiSelect: Boolean
|
||||
protected abstract val shouldShowSelectAll: Boolean
|
||||
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager.NameNotFoundException
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorManager
|
||||
import androidx.annotation.StringRes
|
||||
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.model.*
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.*
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceTariff
|
||||
@@ -18,7 +24,8 @@ import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
class SettingsScreen(ctx: CarContext) : Screen(ctx) {
|
||||
@ExperimentalCarApi
|
||||
class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
|
||||
val prefs = PreferenceDataSource(ctx)
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
@@ -71,7 +78,7 @@ class SettingsScreen(ctx: CarContext) : Screen(ctx) {
|
||||
)
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener {
|
||||
screenManager.push(VehicleDataScreen(carContext))
|
||||
screenManager.push(VehicleDataScreen(carContext, session))
|
||||
}
|
||||
.build()
|
||||
)
|
||||
@@ -81,9 +88,34 @@ class SettingsScreen(ctx: CarContext) : Screen(ctx) {
|
||||
.setToggle(Toggle.Builder {
|
||||
prefs.showChargersAheadAndroidAuto = it
|
||||
}.setChecked(prefs.showChargersAheadAndroidAuto).build())
|
||||
.setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_navigation
|
||||
)
|
||||
).setTint(CarColor.DEFAULT).build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.about))
|
||||
.setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_about
|
||||
)
|
||||
).setTint(CarColor.DEFAULT).build()
|
||||
)
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener {
|
||||
screenManager.push(AboutScreen(carContext))
|
||||
}
|
||||
.build()
|
||||
)
|
||||
}.build())
|
||||
}.build()
|
||||
}
|
||||
@@ -239,9 +271,7 @@ class ChooseDataSourceScreen(
|
||||
|
||||
class ChargepriceSettingsScreen(ctx: CarContext) : Screen(ctx) {
|
||||
val prefs = PreferenceDataSource(carContext)
|
||||
private val maxRows = if (ctx.carAppApiLevel >= 2) {
|
||||
ctx.constraintManager.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
|
||||
} else 6
|
||||
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
return ListTemplate.Builder().apply {
|
||||
@@ -556,4 +586,165 @@ class SelectChargingRangeScreen(ctx: CarContext) : Screen(ctx) {
|
||||
)
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
|
||||
class AboutScreen(ctx: CarContext) : Screen(ctx) {
|
||||
val prefs = PreferenceDataSource(ctx)
|
||||
var developerOptionsCounter = 0
|
||||
private val maxRows = ctx.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
return ListTemplate.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.about))
|
||||
setHeaderAction(Action.BACK)
|
||||
addSectionedList(SectionedItemList.create(ItemList.Builder().apply {
|
||||
addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.version))
|
||||
.addText(BuildConfig.VERSION_NAME)
|
||||
.addText(
|
||||
carContext.getString(R.string.copyright) + " " + carContext.getString(
|
||||
R.string.copyright_summary
|
||||
)
|
||||
)
|
||||
.setBrowsable(prefs.developerModeEnabled)
|
||||
.setOnClickListener {
|
||||
if (!prefs.developerModeEnabled) {
|
||||
developerOptionsCounter += 1
|
||||
if (developerOptionsCounter >= 7) {
|
||||
prefs.developerModeEnabled = true
|
||||
invalidate()
|
||||
CarToast.makeText(
|
||||
carContext,
|
||||
carContext.getString(R.string.developer_mode_enabled),
|
||||
CarToast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
} else {
|
||||
screenManager.pushForResult(DeveloperOptionsScreen(carContext)) {
|
||||
developerOptionsCounter = 0
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
}.build()
|
||||
)
|
||||
addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.faq))
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
openUrl(carContext, carContext.getString(R.string.faq_link))
|
||||
}).build()
|
||||
)
|
||||
addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.donate))
|
||||
.addText(carContext.getString(R.string.donate_desc))
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
if (BuildConfig.FLAVOR_automotive == "automotive") {
|
||||
// we can't open the donation page on the phone in this case
|
||||
openUrl(carContext, carContext.getString(R.string.paypal_link))
|
||||
} else {
|
||||
val intent = Intent(carContext, MapsActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.putExtra(EXTRA_DONATE, true)
|
||||
carContext.startActivity(intent)
|
||||
CarToast.makeText(
|
||||
carContext,
|
||||
R.string.opened_on_phone,
|
||||
CarToast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}).build()
|
||||
)
|
||||
}.build(), carContext.getString(R.string.about)))
|
||||
addSectionedList(SectionedItemList.create(ItemList.Builder().apply {
|
||||
addItem(Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.twitter))
|
||||
.addText(carContext.getString(R.string.twitter_handle))
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
openUrl(carContext, carContext.getString(R.string.twitter_url))
|
||||
}).build()
|
||||
)
|
||||
if (maxRows > 6) {
|
||||
addItem(Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.goingelectric_forum))
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
openUrl(
|
||||
carContext,
|
||||
carContext.getString(R.string.goingelectric_forum_url)
|
||||
)
|
||||
}).build()
|
||||
)
|
||||
}
|
||||
}.build(), carContext.getString(R.string.contact)))
|
||||
addSectionedList(SectionedItemList.create(ItemList.Builder().apply {
|
||||
addItem(Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.github_link_title))
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
openUrl(carContext, carContext.getString(R.string.github_link))
|
||||
}).build()
|
||||
)
|
||||
addItem(Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.privacy))
|
||||
.setBrowsable(true)
|
||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
openUrl(carContext, carContext.getString(R.string.privacy_link))
|
||||
}).build()
|
||||
)
|
||||
}.build(), carContext.getString(R.string.other)))
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
|
||||
class DeveloperOptionsScreen(ctx: CarContext) : Screen(ctx) {
|
||||
val prefs = PreferenceDataSource(ctx)
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
return ListTemplate.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.developer_options))
|
||||
setHeaderAction(Action.BACK)
|
||||
setSingleList(ItemList.Builder().apply {
|
||||
addItem(
|
||||
Row.Builder().apply {
|
||||
setTitle("Car app API Level: ${carContext.carAppApiLevel}")
|
||||
val hostPackage = carContext.hostInfo?.packageName
|
||||
val hostVersion = hostPackage?.let {
|
||||
try {
|
||||
carContext.packageManager.getPackageInfoCompat(it).versionName
|
||||
} catch (e: NameNotFoundException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
addText("$hostPackage $hostVersion")
|
||||
if (BuildConfig.FLAVOR_automotive == "automotive") {
|
||||
addText(
|
||||
"Sensor list: ${
|
||||
(carContext.getSystemService(Context.SENSOR_SERVICE) as SensorManager).getSensorList(
|
||||
Sensor.TYPE_ALL
|
||||
).map { it.type }.joinToString(",")
|
||||
}"
|
||||
)
|
||||
}
|
||||
}.build()
|
||||
)
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.disable_developer_mode))
|
||||
setOnClickListener {
|
||||
prefs.developerModeEnabled = false
|
||||
CarToast.makeText(
|
||||
carContext,
|
||||
carContext.getString(R.string.developer_mode_disabled),
|
||||
CarToast.LENGTH_SHORT
|
||||
).show()
|
||||
screenManager.pop()
|
||||
}
|
||||
}.build())
|
||||
}.build())
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,25 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.browser.customtabs.CustomTabColorSchemeParams
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.CarToast
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.constraints.ConstraintManager
|
||||
import androidx.car.app.hardware.common.CarUnit
|
||||
import androidx.car.app.model.*
|
||||
import androidx.car.app.versioning.CarAppApiLevels
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.api.availability.ChargepointStatus
|
||||
import net.vonforst.evmap.getPackageInfoCompat
|
||||
import java.util.*
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -33,13 +42,32 @@ fun carAvailabilityColor(status: List<ChargepointStatus>): CarColor {
|
||||
val CarContext.constraintManager
|
||||
get() = getCarService(CarContext.CONSTRAINT_SERVICE) as ConstraintManager
|
||||
|
||||
fun CarContext.getContentLimit(id: Int) = if (carAppApiLevel >= 2) {
|
||||
constraintManager.getContentLimit(id)
|
||||
} else {
|
||||
when (id) {
|
||||
ConstraintManager.CONTENT_LIMIT_TYPE_GRID -> 6
|
||||
ConstraintManager.CONTENT_LIMIT_TYPE_LIST -> 6
|
||||
ConstraintManager.CONTENT_LIMIT_TYPE_PANE -> 4
|
||||
ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST -> 6
|
||||
ConstraintManager.CONTENT_LIMIT_TYPE_ROUTE_LIST -> 3
|
||||
else -> throw IllegalArgumentException("unknown limit ID")
|
||||
}
|
||||
}
|
||||
|
||||
val CarContext.isAppDrivenRefreshSupported
|
||||
@androidx.car.app.annotations.ExperimentalCarApi
|
||||
get() = if (carAppApiLevel >= 6) constraintManager.isAppDrivenRefreshEnabled else false
|
||||
|
||||
fun Bitmap.asCarIcon(): CarIcon = CarIcon.Builder(IconCompat.createWithBitmap(this)).build()
|
||||
|
||||
val emptyCarIcon = Bitmap.createBitmap(
|
||||
1,
|
||||
1,
|
||||
Bitmap.Config.ARGB_8888
|
||||
).asCarIcon()
|
||||
val emptyCarIcon: CarIcon by lazy {
|
||||
Bitmap.createBitmap(
|
||||
1,
|
||||
1,
|
||||
Bitmap.Config.ARGB_8888
|
||||
).asCarIcon()
|
||||
}
|
||||
|
||||
private const val kmPerMile = 1.609344
|
||||
private const val ftPerMile = 5280
|
||||
@@ -134,8 +162,42 @@ private fun roundToMultipleOf(num: Double, step: Double): Double {
|
||||
return (num / step).roundToInt() * step
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginates data based on specific limits for each page.
|
||||
* If the data fits on a single page, this page can have a maximum size nSingle. Otherwise, the
|
||||
* first page has maximum nFirst items, the last page nLast items, and all intermediate pages nOther
|
||||
* items.
|
||||
*/
|
||||
fun <T> List<T>.paginate(nSingle: Int, nFirst: Int, nOther: Int, nLast: Int): List<List<T>> {
|
||||
if (nOther > nLast) {
|
||||
throw IllegalArgumentException("nLast has to be larger than or equal to nOther")
|
||||
}
|
||||
return if (size <= nSingle) {
|
||||
listOf(this)
|
||||
} else {
|
||||
val result = mutableListOf<List<T>>()
|
||||
var i = 0
|
||||
var page = 0
|
||||
while (true) {
|
||||
val remaining = size - i
|
||||
if (page == 0) {
|
||||
result.add(subList(i, i + nFirst))
|
||||
i += nFirst
|
||||
} else if (remaining <= nLast) {
|
||||
result.add(subList(i, size))
|
||||
break
|
||||
} else {
|
||||
result.add(subList(i, i + nOther))
|
||||
i += nOther
|
||||
}
|
||||
page++
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fun getAndroidAutoVersion(ctx: Context): List<String> {
|
||||
val info = ctx.packageManager.getPackageInfo("com.google.android.projection.gearhead", 0)
|
||||
val info = ctx.packageManager.getPackageInfoCompat("com.google.android.projection.gearhead", 0)
|
||||
return info.versionName.split(".")
|
||||
}
|
||||
|
||||
@@ -154,6 +216,40 @@ fun supportsCarApiLevel3(ctx: CarContext): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
fun openUrl(carContext: CarContext, url: String) {
|
||||
val intent = CustomTabsIntent.Builder()
|
||||
.setDefaultColorSchemeParams(
|
||||
CustomTabColorSchemeParams.Builder()
|
||||
.setToolbarColor(
|
||||
ContextCompat.getColor(
|
||||
carContext,
|
||||
R.color.colorPrimary
|
||||
)
|
||||
)
|
||||
.build()
|
||||
)
|
||||
.build().intent
|
||||
intent.data = Uri.parse(url)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
try {
|
||||
carContext.startActivity(intent)
|
||||
if (BuildConfig.FLAVOR_automotive != "automotive") {
|
||||
// only show the toast "opened on phone" if we're running on a phone
|
||||
CarToast.makeText(
|
||||
carContext,
|
||||
R.string.opened_on_phone,
|
||||
CarToast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
CarToast.makeText(
|
||||
carContext,
|
||||
R.string.no_browser_app_found,
|
||||
CarToast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
class DummyReturnScreen(ctx: CarContext) : Screen(ctx) {
|
||||
/*
|
||||
Dummy screen to get around template refresh limitations.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.car.app.CarContext
|
||||
@@ -16,10 +17,13 @@ import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.ui.CompassNeedle
|
||||
import net.vonforst.evmap.ui.Gauge
|
||||
import net.vonforst.evmap.utils.formatDecimal
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class VehicleDataScreen(ctx: CarContext) : Screen(ctx), DefaultLifecycleObserver {
|
||||
@androidx.car.app.annotations.ExperimentalCarApi
|
||||
class VehicleDataScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx),
|
||||
LocationAwareScreen, DefaultLifecycleObserver {
|
||||
private val carInfo =
|
||||
(ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager).carInfo
|
||||
private val carSensors = carContext.patchedCarSensors
|
||||
@@ -27,6 +31,7 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), DefaultLifecycleObserver
|
||||
private var energyLevel: EnergyLevel? = null
|
||||
private var speed: Speed? = null
|
||||
private var heading: Compass? = null
|
||||
private var location: Location? = null
|
||||
private var gauge = Gauge((ctx.resources.displayMetrics.density * 128).roundToInt(), ctx)
|
||||
private var compass =
|
||||
CompassNeedle((ctx.resources.displayMetrics.density * 128).roundToInt(), ctx)
|
||||
@@ -70,7 +75,11 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), DefaultLifecycleObserver
|
||||
val energyLevel = energyLevel
|
||||
val model = model
|
||||
val speed = speed
|
||||
val heading = heading
|
||||
val location = location
|
||||
|
||||
val compassHeading = heading?.orientations?.value?.get(0)
|
||||
val gpsHeading = if (location?.hasBearing() == true) location.bearing else null
|
||||
val heading = compassHeading ?: gpsHeading
|
||||
|
||||
return GridTemplate.Builder().apply {
|
||||
setTitle(
|
||||
@@ -192,17 +201,30 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), DefaultLifecycleObserver
|
||||
if (heading == null) {
|
||||
setLoading(true)
|
||||
} else {
|
||||
val heading = heading.orientations.value
|
||||
if (heading != null) {
|
||||
setText(
|
||||
"${heading[0].roundToInt()}°"
|
||||
val headingSource =
|
||||
if (compassHeading != null) carContext.getString(R.string.compass) else carContext.getString(
|
||||
R.string.gps
|
||||
)
|
||||
|
||||
} else {
|
||||
setText(carContext.getString(R.string.auto_no_data))
|
||||
}
|
||||
setText("${heading.roundToInt()}° ($headingSource)")
|
||||
setImage(
|
||||
compass.draw(heading?.get(0)).asCarIcon()
|
||||
compass.draw(heading).asCarIcon()
|
||||
)
|
||||
}
|
||||
}.build())
|
||||
addItem(GridItem.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.coordinates))
|
||||
if (location == null) {
|
||||
setLoading(true)
|
||||
} else {
|
||||
val dms = location.formatDecimal(4)
|
||||
setText(dms)
|
||||
setImage(
|
||||
CarIcon.Builder(
|
||||
IconCompat.createWithResource(
|
||||
carContext,
|
||||
R.drawable.ic_location
|
||||
)
|
||||
).setTint(CarColor.DEFAULT).build()
|
||||
)
|
||||
}
|
||||
}.build())
|
||||
@@ -229,6 +251,7 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), DefaultLifecycleObserver
|
||||
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
setupListeners()
|
||||
session.mapScreen = this
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
@@ -253,6 +276,7 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), DefaultLifecycleObserver
|
||||
|
||||
override fun onPause(owner: LifecycleOwner) {
|
||||
removeListeners()
|
||||
session.mapScreen = null
|
||||
}
|
||||
|
||||
private fun removeListeners() {
|
||||
@@ -269,4 +293,8 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx), DefaultLifecycleObserver
|
||||
it
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
override fun updateLocation(location: Location) {
|
||||
this.location = location
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import android.text.style.StyleSpan
|
||||
import com.car2go.maps.google.adapter.AnyMapAdapter
|
||||
import com.car2go.maps.util.SphericalUtil
|
||||
import com.google.android.gms.common.api.ApiException
|
||||
import com.google.android.gms.common.api.CommonStatusCodes
|
||||
import com.google.android.gms.maps.model.LatLng
|
||||
import com.google.android.gms.maps.model.LatLngBounds
|
||||
import com.google.android.gms.tasks.Tasks.await
|
||||
@@ -19,6 +20,7 @@ import com.google.android.libraries.places.api.net.FindAutocompletePredictionsRe
|
||||
import com.google.android.libraries.places.api.net.PlacesStatusCodes
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import net.vonforst.evmap.R
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.ExecutionException
|
||||
import kotlin.math.sqrt
|
||||
|
||||
@@ -58,6 +60,13 @@ class GooglePlacesAutocompleteProvider(val context: Context) : AutocompleteProvi
|
||||
if (cause is ApiException) {
|
||||
if (cause.statusCode == PlacesStatusCodes.OVER_QUERY_LIMIT) {
|
||||
throw ApiUnavailableException()
|
||||
} else if (cause.statusCode in listOf(
|
||||
CommonStatusCodes.NETWORK_ERROR,
|
||||
CommonStatusCodes.TIMEOUT, CommonStatusCodes.RECONNECTION_TIMED_OUT,
|
||||
CommonStatusCodes.RECONNECTION_TIMED_OUT_DURING_UPDATE
|
||||
)
|
||||
) {
|
||||
throw IOException(cause)
|
||||
}
|
||||
}
|
||||
throw e
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.android.billingclient.api.*
|
||||
import com.android.billingclient.api.BillingClient.ProductType
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.adapter.Equatable
|
||||
|
||||
@@ -24,10 +25,15 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
|
||||
loadProducts()
|
||||
|
||||
// consume pending purchases
|
||||
val purchases = billingClient.queryPurchases(BillingClient.SkuType.INAPP)
|
||||
purchases.purchasesList?.forEach {
|
||||
if (!it.isAcknowledged) {
|
||||
consumePurchase(it.purchaseToken, false)
|
||||
billingClient.queryPurchasesAsync(
|
||||
QueryPurchasesParams.newBuilder()
|
||||
.setProductType(ProductType.INAPP)
|
||||
.build()
|
||||
) { _, purchasesList ->
|
||||
purchasesList.forEach {
|
||||
if (!it.isAcknowledged) {
|
||||
consumePurchase(it.purchaseToken, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,26 +42,26 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
|
||||
}
|
||||
|
||||
private fun loadProducts() {
|
||||
val params = SkuDetailsParams.newBuilder()
|
||||
.setType(BillingClient.SkuType.INAPP)
|
||||
.setSkusList(
|
||||
listOf(
|
||||
"donate_1_eur", "donate_2_eur", "donate_5_eur", "donate_10_eur"
|
||||
) +
|
||||
if (BuildConfig.DEBUG) {
|
||||
listOf(
|
||||
"android.test.purchased", "android.test.canceled",
|
||||
"android.test.item_unavailable"
|
||||
)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
val productIds = listOf(
|
||||
"donate_1_eur", "donate_2_eur", "donate_5_eur", "donate_10_eur"
|
||||
) + if (BuildConfig.DEBUG) {
|
||||
listOf(
|
||||
"android.test.purchased", "android.test.canceled",
|
||||
"android.test.item_unavailable"
|
||||
)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
val params = QueryProductDetailsParams.newBuilder()
|
||||
.setProductList(productIds.map {
|
||||
QueryProductDetailsParams.Product.newBuilder().setProductType(ProductType.INAPP)
|
||||
.setProductId(it).build()
|
||||
})
|
||||
.build()
|
||||
billingClient.querySkuDetailsAsync(params) { result, details ->
|
||||
if (result.responseCode == BillingClient.BillingResponseCode.OK && details != null) {
|
||||
billingClient.queryProductDetailsAsync(params) { result, details ->
|
||||
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||
products.postValue(Resource.success(details
|
||||
.sortedBy { it.priceAmountMicros }
|
||||
.sortedBy { it.oneTimePurchaseOfferDetails!!.priceAmountMicros }
|
||||
.map { DonationItem(it) }
|
||||
))
|
||||
} else {
|
||||
@@ -97,7 +103,13 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
|
||||
|
||||
fun startPurchase(it: DonationItem, activity: Activity) {
|
||||
val flowParams = BillingFlowParams.newBuilder()
|
||||
.setSkuDetails(it.sku)
|
||||
.setProductDetailsParamsList(
|
||||
listOf(
|
||||
BillingFlowParams.ProductDetailsParams.newBuilder()
|
||||
.setProductDetails(it.product)
|
||||
.build()
|
||||
)
|
||||
)
|
||||
.build()
|
||||
val response = billingClient.launchBillingFlow(activity, flowParams)
|
||||
if (response.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||
@@ -110,4 +122,4 @@ class DonateViewModel(application: Application) : AndroidViewModel(application),
|
||||
}
|
||||
}
|
||||
|
||||
data class DonationItem(val sku: SkuDetails) : Equatable
|
||||
data class DonationItem(val product: ProductDetails) : Equatable
|
||||
@@ -28,7 +28,7 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="@{item.sku.title}"
|
||||
android:text="@{item.product.title}"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/textView21"
|
||||
@@ -41,7 +41,7 @@
|
||||
android:id="@+id/textView21"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@{item.sku.price}"
|
||||
android:text="@{item.product.oneTimePurchaseOfferDetails.formattedPrice}"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
|
||||
@@ -34,4 +34,6 @@
|
||||
<string name="selecting_all">alle Einträge ausgewählt</string>
|
||||
<string name="selecting_none">alle Einträge abgewählt</string>
|
||||
<string name="loading">Lade…</string>
|
||||
<string name="auto_multipage_goto">Seite %d</string>
|
||||
<string name="auto_multipage">(%d/%d)</string>
|
||||
</resources>
|
||||
@@ -34,4 +34,5 @@
|
||||
<string name="auto_no_refresh_possible">D\'autres mises à jour ne sont pas possibles. Veuillez revenir en arrière et redémarrer.</string>
|
||||
<string name="settings_android_auto_chargeprice_range">Plage de charge pour la comparaison des prix</string>
|
||||
<string name="welcome_android_auto_detail">Vous pouvez également utiliser EVMap à partir d\'Android Auto sur les voitures prises en charge. Il suffit de sélectionner l\'application EVMap dans le menu Android Auto.</string>
|
||||
<string name="loading">Chargement…</string>
|
||||
</resources>
|
||||
@@ -34,4 +34,8 @@
|
||||
<string name="data_sources_hint">I innstillingene kan du også bytte mellom Google Maps og OpenStreetMap (Mapbox) for kartdata.</string>
|
||||
<string name="selecting_all">valgte alle elementene</string>
|
||||
<string name="sounds_cool">den er grei</string>
|
||||
<string name="auto_chargers_ahead">Kun ladere i kjøreretningen</string>
|
||||
<string name="loading">Laster inn …</string>
|
||||
<string name="auto_multipage_goto">Side %d</string>
|
||||
<string name="auto_multipage">(%d/%d)</string>
|
||||
</resources>
|
||||
@@ -34,4 +34,6 @@
|
||||
<string name="selecting_all">selected all items</string>
|
||||
<string name="selecting_none">deselected all items</string>
|
||||
<string name="loading">Loading…</string>
|
||||
<string name="auto_multipage_goto">Page %d</string>
|
||||
<string name="auto_multipage">(%d/%d)</string>
|
||||
</resources>
|
||||
@@ -4,6 +4,7 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
@@ -14,6 +15,9 @@
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="google.navigation" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.support.customtabs.action.CustomTabsService" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
@@ -252,6 +256,10 @@
|
||||
android:host="www.goingelectric.de"
|
||||
android:pathPattern="/stromtankstellen/Ungarn/..*/..*/..*/"
|
||||
android:scheme="https" />
|
||||
<data
|
||||
android:host="openchargemap.org"
|
||||
android:pathPattern="/site/poi/details/..*"
|
||||
android:scheme="https" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
@@ -8,8 +8,10 @@ import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.browser.customtabs.CustomTabColorSchemeParams
|
||||
import androidx.browser.customtabs.CustomTabsClient
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.splashscreen.SplashScreen
|
||||
@@ -38,6 +40,7 @@ const val EXTRA_CHARGER_ID = "chargerId"
|
||||
const val EXTRA_LAT = "lat"
|
||||
const val EXTRA_LON = "lon"
|
||||
const val EXTRA_FAVORITES = "favorites"
|
||||
const val EXTRA_DONATE = "donate"
|
||||
|
||||
class MapsActivity : AppCompatActivity(),
|
||||
PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {
|
||||
@@ -73,7 +76,7 @@ class MapsActivity : AppCompatActivity(),
|
||||
val navView = findViewById<NavigationView>(R.id.nav_view)
|
||||
navView.setupWithNavController(navController)
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(navView) { v, insets ->
|
||||
ViewCompat.setOnApplyWindowInsetsListener(navView) { _, insets ->
|
||||
val header = navView.getHeaderView(0)
|
||||
header.setPadding(0, insets.getInsets(WindowInsetsCompat.Type.statusBars()).top, 0, 0)
|
||||
insets
|
||||
@@ -131,6 +134,37 @@ class MapsActivity : AppCompatActivity(),
|
||||
} else if (intent?.scheme == "https" && intent?.data?.host == "www.goingelectric.de") {
|
||||
val id = intent.data?.pathSegments?.last()?.toLongOrNull()
|
||||
if (id != null) {
|
||||
if (prefs.dataSource != "goingelectric") {
|
||||
prefs.dataSource = "goingelectric"
|
||||
Toast.makeText(
|
||||
this,
|
||||
getString(
|
||||
R.string.data_source_switched_to,
|
||||
getString(R.string.data_source_goingelectric)
|
||||
),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
.setDestination(R.id.map)
|
||||
.setArguments(MapFragmentArgs(chargerId = id).toBundle())
|
||||
.createPendingIntent()
|
||||
}
|
||||
} else if (intent?.scheme == "https" && intent?.data?.host == "openchargemap.org") {
|
||||
val id = intent.data?.pathSegments?.last()?.toLongOrNull()
|
||||
if (id != null) {
|
||||
if (prefs.dataSource != "openchargemap") {
|
||||
prefs.dataSource = "openchargemap"
|
||||
Toast.makeText(
|
||||
this,
|
||||
getString(
|
||||
R.string.data_source_switched_to,
|
||||
getString(R.string.data_source_openchargemap)
|
||||
),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(R.navigation.nav_graph)
|
||||
.setDestination(R.id.map)
|
||||
@@ -155,6 +189,11 @@ class MapsActivity : AppCompatActivity(),
|
||||
.setGraph(navGraph)
|
||||
.setDestination(R.id.favs)
|
||||
.createPendingIntent()
|
||||
} else if (intent.hasExtra(EXTRA_DONATE)) {
|
||||
deepLink = navController.createDeepLink()
|
||||
.setGraph(navGraph)
|
||||
.setDestination(R.id.donate)
|
||||
.createPendingIntent()
|
||||
}
|
||||
|
||||
deepLink?.send()
|
||||
@@ -196,6 +235,7 @@ class MapsActivity : AppCompatActivity(),
|
||||
}
|
||||
|
||||
fun openUrl(url: String) {
|
||||
val pkg = CustomTabsClient.getPackageName(this, null)
|
||||
val intent = CustomTabsIntent.Builder()
|
||||
.setDefaultColorSchemeParams(
|
||||
CustomTabColorSchemeParams.Builder()
|
||||
@@ -203,6 +243,11 @@ class MapsActivity : AppCompatActivity(),
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
pkg?.let {
|
||||
// prefer to open URL in custom tab, even if native app
|
||||
// available (such as EVMap itself)
|
||||
intent.intent.setPackage(pkg)
|
||||
}
|
||||
try {
|
||||
intent.launchUrl(this, Uri.parse(url))
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package net.vonforst.evmap
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Typeface
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.*
|
||||
import android.text.style.StyleSpan
|
||||
@@ -72,7 +75,7 @@ fun max(a: Int?, b: Int?): Int? {
|
||||
* otherwise the non-null value or null
|
||||
*/
|
||||
return if (a != null && b != null) {
|
||||
max(a, b)
|
||||
kotlin.math.max(a, b)
|
||||
} else {
|
||||
a ?: b
|
||||
}
|
||||
@@ -88,4 +91,11 @@ const val meterPerFt = 0.3048
|
||||
|
||||
fun shouldUseImperialUnits(): Boolean {
|
||||
return Locale.getDefault().country in listOf("US", "GB", "MM", "LR")
|
||||
}
|
||||
}
|
||||
|
||||
fun PackageManager.getPackageInfoCompat(packageName: String, flags: Int = 0): PackageInfo =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(flags.toLong()))
|
||||
} else {
|
||||
@Suppress("DEPRECATION") getPackageInfo(packageName, flags)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -161,11 +160,12 @@ class CheckableConnectorAdapter : DataBindingAdapter<Chargepoint>() {
|
||||
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 { v: View, checked: Boolean ->
|
||||
root.setOnCheckedChangeListener { _, checked: Boolean ->
|
||||
if (checked) {
|
||||
checkedItem = holder.bindingAdapterPosition.takeIf { it != -1 }
|
||||
root.post {
|
||||
@@ -204,7 +204,7 @@ class CheckableChargepriceCarAdapter : DataBindingAdapter<ChargepriceCar>() {
|
||||
root.setOnClickListener {
|
||||
root.isChecked = true
|
||||
}
|
||||
root.setOnCheckedChangeListener { v: View, checked: Boolean ->
|
||||
root.setOnCheckedChangeListener { _, checked: Boolean ->
|
||||
if (checked && item != checkedItem) {
|
||||
checkedItem = item
|
||||
root.post {
|
||||
|
||||
@@ -10,6 +10,8 @@ import net.vonforst.evmap.model.ChargeCardId
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.OpeningHoursDays
|
||||
import net.vonforst.evmap.plus
|
||||
import net.vonforst.evmap.utils.formatDMS
|
||||
import net.vonforst.evmap.utils.formatDecimal
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
@@ -25,7 +25,7 @@ class FilterProfilesAdapter(
|
||||
super.bind(holder, item)
|
||||
|
||||
val binding = holder.binding as ItemFilterProfileBinding
|
||||
binding.handle.setOnTouchListener { v, event ->
|
||||
binding.handle.setOnTouchListener { _, event ->
|
||||
if (event?.action == MotionEvent.ACTION_DOWN) {
|
||||
dragHelper.startDrag(holder)
|
||||
}
|
||||
|
||||
@@ -71,19 +71,19 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
|
||||
connectors: Map<Long, Pair<Double, String>>,
|
||||
chargepoints: List<Chargepoint>
|
||||
): Map<Chargepoint, Set<Long>> {
|
||||
var chargepoints = chargepoints
|
||||
var cpts = chargepoints
|
||||
|
||||
// iterate over each connector type
|
||||
val types = connectors.map { it.value.second }.distinct().toSet()
|
||||
val equivalentTypes = types.map { equivalentPlugTypes(it).plus(it) }.cartesianProduct()
|
||||
var geTypes = chargepoints.map { it.type }.distinct().toSet()
|
||||
var geTypes = cpts.map { it.type }.distinct().toSet()
|
||||
if (!equivalentTypes.any { it == geTypes } && geTypes.size > 1 && geTypes.contains(
|
||||
Chargepoint.SCHUKO
|
||||
)) {
|
||||
// If charger has household plugs and other plugs, try removing the household plugs
|
||||
// (common e.g. in Hamburg -> 2x Type 2 + 2x Schuko, but NM only lists Type 2)
|
||||
geTypes = geTypes.filter { it != Chargepoint.SCHUKO }.toSet()
|
||||
chargepoints = chargepoints.filter { it.type != Chargepoint.SCHUKO }
|
||||
cpts = cpts.filter { it.type != Chargepoint.SCHUKO }
|
||||
}
|
||||
if (!equivalentTypes.any { it == geTypes }) throw AvailabilityDetectorException("chargepoints do not match")
|
||||
return types.flatMap { type ->
|
||||
@@ -93,14 +93,14 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
|
||||
val powers = connsOfType.map { it.value.first }.distinct().sorted()
|
||||
// find corresponding powers in GE data
|
||||
val gePowers =
|
||||
chargepoints.filter { equivalentPlugTypes(it.type).any { it == type } }
|
||||
cpts.filter { equivalentPlugTypes(it.type).any { it == type } }
|
||||
.mapNotNull { it.power }.distinct().sorted()
|
||||
|
||||
// if the distinct number of powers is the same, try to match.
|
||||
if (powers.size == gePowers.size) {
|
||||
gePowers.zip(powers).map { (gePower, power) ->
|
||||
val chargepoint =
|
||||
chargepoints.find { equivalentPlugTypes(it.type).any { it == type } && it.power == gePower }!!
|
||||
cpts.find { equivalentPlugTypes(it.type).any { it == type } && it.power == gePower }!!
|
||||
val ids = connsOfType.filter { it.value.first == power }.keys
|
||||
if (chargepoint.count != ids.size) {
|
||||
throw AvailabilityDetectorException("chargepoints do not match")
|
||||
@@ -108,7 +108,7 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
|
||||
chargepoint to ids
|
||||
}
|
||||
} else if (powers.size == 1 && gePowers.size == 2
|
||||
&& chargepoints.sumOf { it.count } == connsOfType.size
|
||||
&& cpts.sumOf { it.count } == connsOfType.size
|
||||
) {
|
||||
// special case: dual charger(s) with load balancing
|
||||
// GoingElectric shows 2 different powers, NewMotion just one
|
||||
@@ -116,7 +116,7 @@ abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : Avai
|
||||
var i = 0
|
||||
gePowers.map { gePower ->
|
||||
val chargepoint =
|
||||
chargepoints.find { it.type in equivalentPlugTypes(type) && it.power == gePower }!!
|
||||
cpts.find { it.type in equivalentPlugTypes(type) && it.power == gePower }!!
|
||||
val ids = allIds.subList(i, i + chargepoint.count).toSet()
|
||||
i += chargepoint.count
|
||||
chargepoint to ids
|
||||
|
||||
@@ -51,7 +51,7 @@ interface ChargecloudApi {
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun create(client: OkHttpClient, baseUrl: String? = null): ChargecloudApi {
|
||||
fun create(client: OkHttpClient, baseUrl: String): ChargecloudApi {
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.addConverterFactory(MoshiConverterFactory.create())
|
||||
|
||||
@@ -140,7 +140,7 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
|
||||
connectorStatus.forEach { (connector, statusStr, evseId) ->
|
||||
val id = connector.uid
|
||||
val power = connector.electricalProperties.getPower()
|
||||
val type = when (connector.connectorType.toLowerCase(Locale.ROOT)) {
|
||||
val type = when (connector.connectorType.lowercase(Locale.ROOT)) {
|
||||
"type3" -> Chargepoint.TYPE_3
|
||||
"type2" -> Chargepoint.TYPE_2_UNKNOWN
|
||||
"type1" -> Chargepoint.TYPE_1
|
||||
|
||||
@@ -80,7 +80,9 @@ data class ChargepriceOptions(
|
||||
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 = "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")
|
||||
@@ -268,7 +270,7 @@ internal object RelationshipsParceler : Parceler<Relationships?> {
|
||||
data class ChargepointPrice(
|
||||
val power: Double,
|
||||
val plug: String,
|
||||
val price: Double,
|
||||
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?
|
||||
|
||||
@@ -14,13 +14,18 @@ import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface FronyxApi {
|
||||
private interface FronyxApiRetrofit {
|
||||
@GET("predictions/evse-id/{evseId}")
|
||||
suspend fun getPredictionsForEvseId(
|
||||
@Path("evseId") evseId: String,
|
||||
@Query("timeframe") timeframe: Int? = null
|
||||
): FronyxEvseIdResponse
|
||||
|
||||
@GET("predictions/evses")
|
||||
suspend fun getPredictionsForEvseIds(
|
||||
@Query("evseIds", encoded = true) evseIds: String // comma-separated
|
||||
): List<FronyxEvseIdResponse>
|
||||
|
||||
companion object {
|
||||
private val cacheSize = 1L * 1024 * 1024 // 1MB
|
||||
|
||||
@@ -32,7 +37,7 @@ interface FronyxApi {
|
||||
apikey: String,
|
||||
baseurl: String = "https://api.fronyx.io/api/",
|
||||
context: Context? = null
|
||||
): FronyxApi {
|
||||
): FronyxApiRetrofit {
|
||||
val client = OkHttpClient.Builder().apply {
|
||||
addInterceptor { chain ->
|
||||
// add API key to every request
|
||||
@@ -56,9 +61,28 @@ interface FronyxApi {
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.client(client)
|
||||
.build()
|
||||
return retrofit.create(FronyxApi::class.java)
|
||||
return retrofit.create(FronyxApiRetrofit::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FronyxApi(
|
||||
apikey: String,
|
||||
baseurl: String = "https://api.fronyx.io/api/",
|
||||
context: Context? = null
|
||||
) {
|
||||
private val api = FronyxApiRetrofit.create(apikey, baseurl, context)
|
||||
|
||||
suspend fun getPredictionsForEvseId(
|
||||
evseId: String,
|
||||
timeframe: Int? = null
|
||||
): FronyxEvseIdResponse = api.getPredictionsForEvseId(evseId, timeframe)
|
||||
|
||||
suspend fun getPredictionsForEvseIds(
|
||||
evseIds: List<String>
|
||||
): List<FronyxEvseIdResponse> = api.getPredictionsForEvseIds(evseIds.joinToString(","))
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Checks if a chargepoint is supported by Fronyx.
|
||||
*
|
||||
@@ -6,7 +6,8 @@ import java.time.ZonedDateTime
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class FronyxEvseIdResponse(
|
||||
val evseId: String,
|
||||
val predictions: List<FronyxPrediction>
|
||||
val predictions: List<FronyxPrediction>,
|
||||
val locationId: String?
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
|
||||
@@ -71,10 +71,10 @@ internal class JsonObjectOrFalseAdapter<T> private constructor(
|
||||
private val clazz: Class<*>
|
||||
) : JsonAdapter<T>() {
|
||||
|
||||
class Factory() : JsonAdapter.Factory {
|
||||
class Factory : JsonAdapter.Factory {
|
||||
override fun create(
|
||||
type: Type,
|
||||
annotations: Set<Annotation>?,
|
||||
annotations: Set<Annotation>,
|
||||
moshi: Moshi
|
||||
): JsonAdapter<Any>? {
|
||||
val clazz = Types.getRawType(type)
|
||||
|
||||
@@ -399,10 +399,10 @@ class GoingElectricApiWrapper(
|
||||
referenceData: ReferenceData,
|
||||
sp: StringProvider
|
||||
): List<Filter<FilterValue>> {
|
||||
val referenceData = referenceData as GEReferenceData
|
||||
val plugs = referenceData.plugs
|
||||
val networks = referenceData.networks
|
||||
val chargeCards = referenceData.chargecards
|
||||
val refData = referenceData as GEReferenceData
|
||||
val plugs = refData.plugs
|
||||
val networks = refData.networks
|
||||
val chargeCards = refData.chargecards
|
||||
|
||||
val plugMap = plugs.map { plug ->
|
||||
plug to nameForPlugType(sp, GEChargepoint.convertTypeFromGE(plug))
|
||||
|
||||
@@ -120,7 +120,7 @@ class OpenChargeMapApiWrapper(
|
||||
zoom: Float,
|
||||
filters: FilterValues?,
|
||||
): Resource<List<ChargepointListItem>> {
|
||||
val referenceData = referenceData as OCMReferenceData
|
||||
val refData = referenceData as OCMReferenceData
|
||||
|
||||
val minPower = filters?.getSliderValue("min_power")?.toDouble()
|
||||
val minConnectors = filters?.getSliderValue("min_connectors")
|
||||
@@ -133,7 +133,7 @@ class OpenChargeMapApiWrapper(
|
||||
}
|
||||
val connectors = formatMultipleChoice(connectorsVal)
|
||||
|
||||
val operatorsVal = filters?.getMultipleChoiceValue("operators")!!
|
||||
val operatorsVal = filters?.getMultipleChoiceValue("operators")
|
||||
if (operatorsVal != null && operatorsVal.values.isEmpty() && !operatorsVal.all) {
|
||||
// no operators chosen
|
||||
return Resource.success(emptyList())
|
||||
@@ -160,7 +160,7 @@ class OpenChargeMapApiWrapper(
|
||||
minPower,
|
||||
connectorsVal,
|
||||
minConnectors,
|
||||
referenceData,
|
||||
refData,
|
||||
zoom
|
||||
)
|
||||
return Resource.success(result)
|
||||
@@ -176,7 +176,7 @@ class OpenChargeMapApiWrapper(
|
||||
zoom: Float,
|
||||
filters: FilterValues?
|
||||
): Resource<List<ChargepointListItem>> {
|
||||
val referenceData = referenceData as OCMReferenceData
|
||||
val refData = referenceData as OCMReferenceData
|
||||
|
||||
val minPower = filters?.getSliderValue("min_power")?.toDouble()
|
||||
val minConnectors = filters?.getSliderValue("min_connectors")
|
||||
@@ -214,7 +214,7 @@ class OpenChargeMapApiWrapper(
|
||||
minPower,
|
||||
connectorsVal,
|
||||
minConnectors,
|
||||
referenceData,
|
||||
refData,
|
||||
zoom
|
||||
)
|
||||
return Resource.success(result)
|
||||
@@ -254,11 +254,11 @@ class OpenChargeMapApiWrapper(
|
||||
referenceData: ReferenceData,
|
||||
id: Long
|
||||
): Resource<ChargeLocation> {
|
||||
val referenceData = referenceData as OCMReferenceData
|
||||
val refData = referenceData as OCMReferenceData
|
||||
try {
|
||||
val response = api.getChargepointDetail(id)
|
||||
if (response.isSuccessful && response.body()?.size == 1) {
|
||||
return Resource.success(response.body()!![0].convert(referenceData, true))
|
||||
return Resource.success(response.body()!![0].convert(refData, true))
|
||||
} else {
|
||||
return Resource.error(response.message(), null)
|
||||
}
|
||||
@@ -284,10 +284,10 @@ class OpenChargeMapApiWrapper(
|
||||
referenceData: ReferenceData,
|
||||
sp: StringProvider
|
||||
): List<Filter<FilterValue>> {
|
||||
val referenceData = referenceData as OCMReferenceData
|
||||
val refData = referenceData as OCMReferenceData
|
||||
|
||||
val operatorsMap = referenceData.operators.map { it.id.toString() to it.title }.toMap()
|
||||
val plugMap = referenceData.connectionTypes.map { it.id.toString() to it.title }.toMap()
|
||||
val operatorsMap = refData.operators.map { it.id.toString() to it.title }.toMap()
|
||||
val plugMap = refData.connectionTypes.map { it.id.toString() to it.title }.toMap()
|
||||
|
||||
return listOf(
|
||||
// supported by OCM API
|
||||
|
||||
@@ -167,7 +167,7 @@ class ChargepriceFragment : Fragment() {
|
||||
chargepriceAdapter.myTariffsAll = it
|
||||
}
|
||||
vm.chargePricesForChargepoint.observe(viewLifecycleOwner) {
|
||||
chargepriceAdapter.submitList(it.data)
|
||||
it?.data?.let { chargepriceAdapter.submitList(it) }
|
||||
}
|
||||
|
||||
val connectorsAdapter = CheckableConnectorAdapter()
|
||||
|
||||
@@ -44,8 +44,7 @@ class FavoritesFragment : Fragment() {
|
||||
private val vm: FavoritesViewModel by viewModels(factoryProducer = {
|
||||
viewModelFactory {
|
||||
FavoritesViewModel(
|
||||
requireActivity().application,
|
||||
getString(R.string.goingelectric_key)
|
||||
requireActivity().application
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -98,6 +98,12 @@ class FilterFragment : Fragment(), MenuProvider {
|
||||
saveProfile()
|
||||
true
|
||||
}
|
||||
R.id.menu_reset -> {
|
||||
lifecycleScope.launch {
|
||||
vm.resetValues()
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
@@ -114,7 +120,7 @@ class FilterFragment : Fragment(), MenuProvider {
|
||||
|
||||
dialog.setTitle(R.string.save_as_profile)
|
||||
.setMessage(R.string.save_profile_enter_name)
|
||||
.setPositiveButton(R.string.ok) { di, button ->
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
if (input.text.isBlank()) {
|
||||
saveProfile(true)
|
||||
} else {
|
||||
@@ -124,7 +130,7 @@ class FilterFragment : Fragment(), MenuProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.cancel) { di, button ->
|
||||
.setNegativeButton(R.string.cancel) { _, _ ->
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,12 +188,12 @@ class FilterProfilesFragment : Fragment() {
|
||||
|
||||
dialog.setTitle(R.string.rename)
|
||||
.setMessage(R.string.save_profile_enter_name)
|
||||
.setPositiveButton(R.string.ok) { di, button ->
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
lifecycleScope.launch {
|
||||
vm.update(fp.copy(name = input.text.toString()))
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.cancel) { di, button ->
|
||||
.setNegativeButton(R.string.cancel) { _, _ ->
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ import kotlin.collections.component1
|
||||
import kotlin.collections.component2
|
||||
import kotlin.collections.contains
|
||||
import kotlin.collections.set
|
||||
import kotlin.math.min
|
||||
|
||||
|
||||
class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallback, MenuProvider {
|
||||
@@ -197,7 +198,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(
|
||||
binding.root
|
||||
) { v, insets ->
|
||||
) { _, insets ->
|
||||
ViewCompat.onApplyWindowInsets(binding.root, insets)
|
||||
|
||||
val systemWindowInsetTop = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top
|
||||
@@ -465,7 +466,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
}
|
||||
)
|
||||
}
|
||||
binding.search.onFocusChangeListener = View.OnFocusChangeListener { v, hasFocus ->
|
||||
binding.search.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus ->
|
||||
if (hasFocus) {
|
||||
binding.search.keyListener = searchKeyListener
|
||||
binding.search.text = binding.search.text // workaround to fix copy/paste
|
||||
@@ -553,7 +554,17 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
bottomSheetBehavior.addBottomSheetCallback(object :
|
||||
BottomSheetBehaviorGoogleMapsLike.BottomSheetCallback() {
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) {
|
||||
|
||||
if (bottomSheetBehavior.state == STATE_HIDDEN) {
|
||||
map?.setPadding(0, mapTopPadding, 0, 0)
|
||||
} else {
|
||||
val height = binding.root.height - bottomSheet.top
|
||||
map?.setPadding(
|
||||
0,
|
||||
mapTopPadding,
|
||||
0,
|
||||
min(bottomSheetBehavior.peekHeight, height)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
@@ -561,9 +572,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
updateBackPressedCallback()
|
||||
|
||||
if (vm.layersMenuOpen.value!! && newState !in listOf(
|
||||
BottomSheetBehaviorGoogleMapsLike.STATE_SETTLING,
|
||||
BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN,
|
||||
BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED
|
||||
STATE_SETTLING,
|
||||
STATE_HIDDEN,
|
||||
STATE_COLLAPSED
|
||||
)
|
||||
) {
|
||||
closeLayersMenu()
|
||||
@@ -1189,6 +1200,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
)
|
||||
popup.menuInflater.inflate(R.menu.popup_filter, popup.menu)
|
||||
MenuCompat.setGroupDividerEnabled(popup.menu, true)
|
||||
popup.setForceShowIcon(true)
|
||||
popup.setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.menu_edit_filters -> {
|
||||
|
||||
@@ -71,7 +71,7 @@ class MultiSelectDialog : MaterialDialogFragment() {
|
||||
binding.btnAll.visibility = if (showAllButton) View.VISIBLE else View.INVISIBLE
|
||||
|
||||
items = data.entries.toList()
|
||||
.sortedBy { it.value.toLowerCase(Locale.getDefault()) }
|
||||
.sortedBy { it.value.lowercase(Locale.getDefault()) }
|
||||
.sortedBy {
|
||||
when {
|
||||
selected.contains(it.key) && commonChoices?.contains(it.key) == true -> 0
|
||||
@@ -117,7 +117,7 @@ private fun search(
|
||||
): List<MultiSelectItem> {
|
||||
return items.filter { item ->
|
||||
// search for string within name
|
||||
text.toLowerCase(Locale.getDefault()) in item.name.toLowerCase(Locale.getDefault())
|
||||
text.lowercase(Locale.getDefault()) in item.name.lowercase(Locale.getDefault())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package net.vonforst.evmap.fragment.preference
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.Preference
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.ui.getAppLocale
|
||||
import net.vonforst.evmap.ui.updateAppLocale
|
||||
@@ -20,6 +25,9 @@ class UiSettingsFragment : BaseSettingsFragment() {
|
||||
updateAppLocale(newValue as String)
|
||||
true
|
||||
}
|
||||
|
||||
val appLinkPref = findPreference<Preference>("applink_associate")!!
|
||||
appLinkPref.isVisible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@@ -34,4 +42,21 @@ class UiSettingsFragment : BaseSettingsFragment() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
when (preference.key) {
|
||||
"applink_associate" -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val context = context ?: return false
|
||||
val intent = Intent(
|
||||
Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS,
|
||||
Uri.parse("package:${context.packageName}")
|
||||
)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,6 @@ import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.*
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.floor
|
||||
|
||||
sealed class ChargepointListItem
|
||||
|
||||
@@ -113,7 +111,7 @@ data class ChargeLocation(
|
||||
|
||||
// check if there is more than one plug for any connector type
|
||||
val chargepointsPerConnector =
|
||||
connectors.map { conn -> chargepoints.filter { it.type == conn }.sumBy { it.count } }
|
||||
connectors.map { conn -> chargepoints.filter { it.type == conn }.sumOf { it.count } }
|
||||
return chargepointsPerConnector.any { it > 1 }
|
||||
}
|
||||
|
||||
@@ -129,13 +127,13 @@ data class ChargeLocation(
|
||||
return variants.map { variant ->
|
||||
val count = chargepoints
|
||||
.filter { it.type == variant.type && it.power == variant.power }
|
||||
.sumBy { it.count }
|
||||
.sumOf { it.count }
|
||||
Chargepoint(variant.type, variant.power, count)
|
||||
}
|
||||
}
|
||||
|
||||
val totalChargepoints: Int
|
||||
get() = chargepoints.sumBy { it.count }
|
||||
get() = chargepoints.sumOf { it.count }
|
||||
|
||||
fun formatChargepoints(sp: StringProvider): String {
|
||||
return chargepointsMerged.map {
|
||||
@@ -343,28 +341,7 @@ data class ChargeLocationCluster(
|
||||
) : ChargepointListItem()
|
||||
|
||||
@Parcelize
|
||||
data class Coordinate(val lat: Double, val lng: Double) : Parcelable {
|
||||
fun formatDMS(): String {
|
||||
return "${dms(lat, false)}, ${dms(lng, true)}"
|
||||
}
|
||||
|
||||
private fun dms(value: Double, lon: Boolean): String {
|
||||
val hemisphere = if (lon) {
|
||||
if (value >= 0) "E" else "W"
|
||||
} else {
|
||||
if (value >= 0) "N" else "S"
|
||||
}
|
||||
val d = abs(value)
|
||||
val degrees = floor(d).toInt()
|
||||
val minutes = floor((d - degrees) * 60).toInt()
|
||||
val seconds = ((d - degrees) * 60 - minutes) * 60
|
||||
return "%d°%02d'%02.1f\"%s".format(Locale.ENGLISH, degrees, minutes, seconds, hemisphere)
|
||||
}
|
||||
|
||||
fun formatDecimal(): String {
|
||||
return "%.6f, %.6f".format(Locale.ENGLISH, lat, lng)
|
||||
}
|
||||
}
|
||||
data class Coordinate(val lat: Double, val lng: Double) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class Address(
|
||||
@@ -374,7 +351,21 @@ data class Address(
|
||||
val street: String?
|
||||
) : Parcelable {
|
||||
override fun toString(): String {
|
||||
return "${street ?: ""}, ${postcode ?: ""} ${city ?: ""}"
|
||||
// TODO: the order here follows a German-style format (i.e. street, postcode city).
|
||||
// in principle this should be country-dependent (e.g. UK has postcode after city)
|
||||
return buildString {
|
||||
street?.let {
|
||||
append(it)
|
||||
append(", ")
|
||||
}
|
||||
postcode?.let {
|
||||
append(it)
|
||||
append(" ")
|
||||
}
|
||||
city?.let {
|
||||
append(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,49 @@
|
||||
package net.vonforst.evmap.storage
|
||||
|
||||
import androidx.lifecycle.liveData
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.room.*
|
||||
import net.vonforst.evmap.model.*
|
||||
|
||||
@Dao
|
||||
abstract class FilterValueDao {
|
||||
@Query("SELECT * FROM booleanfiltervalue WHERE profile = :profile AND dataSource = :dataSource")
|
||||
protected abstract suspend fun getBooleanFilterValues(
|
||||
protected abstract suspend fun getBooleanFilterValuesAsync(
|
||||
profile: Long,
|
||||
dataSource: String
|
||||
): List<BooleanFilterValue>
|
||||
|
||||
@Query("SELECT * FROM multiplechoicefiltervalue WHERE profile = :profile AND dataSource = :dataSource")
|
||||
protected abstract suspend fun getMultipleChoiceFilterValues(
|
||||
protected abstract suspend fun getMultipleChoiceFilterValuesAsync(
|
||||
profile: Long,
|
||||
dataSource: String
|
||||
): List<MultipleChoiceFilterValue>
|
||||
|
||||
@Query("SELECT * FROM sliderfiltervalue WHERE profile = :profile AND dataSource = :dataSource")
|
||||
protected abstract suspend fun getSliderFilterValues(
|
||||
protected abstract suspend fun getSliderFilterValuesAsync(
|
||||
profile: Long,
|
||||
dataSource: String
|
||||
): List<SliderFilterValue>
|
||||
|
||||
@Query("SELECT * FROM booleanfiltervalue WHERE profile = :profile AND dataSource = :dataSource")
|
||||
protected abstract fun getBooleanFilterValues(
|
||||
profile: Long,
|
||||
dataSource: String
|
||||
): LiveData<List<BooleanFilterValue>>
|
||||
|
||||
@Query("SELECT * FROM multiplechoicefiltervalue WHERE profile = :profile AND dataSource = :dataSource")
|
||||
protected abstract fun getMultipleChoiceFilterValues(
|
||||
profile: Long,
|
||||
dataSource: String
|
||||
): LiveData<List<MultipleChoiceFilterValue>>
|
||||
|
||||
@Query("SELECT * FROM sliderfiltervalue WHERE profile = :profile AND dataSource = :dataSource")
|
||||
protected abstract fun getSliderFilterValues(
|
||||
profile: Long,
|
||||
dataSource: String
|
||||
): LiveData<List<SliderFilterValue>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
protected abstract suspend fun insert(vararg values: BooleanFilterValue)
|
||||
|
||||
@@ -58,15 +78,32 @@ abstract class FilterValueDao {
|
||||
if (filterStatus == FILTERS_DISABLED || filterStatus == FILTERS_FAVORITES) {
|
||||
emptyList()
|
||||
} else {
|
||||
getBooleanFilterValues(filterStatus, dataSource) +
|
||||
getMultipleChoiceFilterValues(filterStatus, dataSource) +
|
||||
getSliderFilterValues(filterStatus, dataSource)
|
||||
getBooleanFilterValuesAsync(filterStatus, dataSource) +
|
||||
getMultipleChoiceFilterValuesAsync(filterStatus, dataSource) +
|
||||
getSliderFilterValuesAsync(filterStatus, dataSource)
|
||||
}
|
||||
|
||||
open fun getFilterValues(filterStatus: Long, dataSource: String) = liveData {
|
||||
emit(null)
|
||||
emit(getFilterValuesAsync(filterStatus, dataSource))
|
||||
}
|
||||
open fun getFilterValues(filterStatus: Long, dataSource: String): LiveData<List<FilterValue>?> =
|
||||
if (filterStatus == FILTERS_DISABLED || filterStatus == FILTERS_FAVORITES) {
|
||||
MutableLiveData(emptyList())
|
||||
} else {
|
||||
MediatorLiveData<List<FilterValue>?>().apply {
|
||||
value = null
|
||||
val sources = listOf(
|
||||
getBooleanFilterValues(filterStatus, dataSource),
|
||||
getMultipleChoiceFilterValues(filterStatus, dataSource),
|
||||
getSliderFilterValues(filterStatus, dataSource)
|
||||
)
|
||||
for (source in sources) {
|
||||
addSource(source) {
|
||||
val values = sources.map { it.value }
|
||||
if (values.all { it != null }) {
|
||||
value = values.filterNotNull().flatten()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transaction
|
||||
open suspend fun insert(vararg values: FilterValue) {
|
||||
|
||||
@@ -255,4 +255,10 @@ class PreferenceDataSource(val context: Context) {
|
||||
|
||||
val predictionEnabled: Boolean
|
||||
get() = sp.getBoolean("prediction_enabled", true)
|
||||
|
||||
var developerModeEnabled: Boolean
|
||||
get() = sp.getBoolean("dev_mode_enabled", false)
|
||||
set(value) {
|
||||
sp.edit().putBoolean("dev_mode_enabled", value).apply()
|
||||
}
|
||||
}
|
||||
@@ -82,8 +82,8 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs)
|
||||
textSize = bubbleTextSize.toFloat()
|
||||
}
|
||||
|
||||
private lateinit var graphBounds: Rect
|
||||
private lateinit var bubbleBounds: Rect
|
||||
private var graphBounds: Rect? = null
|
||||
private var bubbleBounds: Rect? = null
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
val bottom = (paddingBottom + legendWidth).roundToInt()
|
||||
@@ -127,6 +127,8 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs)
|
||||
data: SortedMap<ZonedDateTime, Int>,
|
||||
maxValue: Int
|
||||
) {
|
||||
val graphBounds = graphBounds ?: return
|
||||
|
||||
canvas.apply {
|
||||
drawLine(
|
||||
graphBounds.left.toFloat(),
|
||||
@@ -207,11 +209,14 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs)
|
||||
if (v < maxValue) colorAvailable else colorUnavailable
|
||||
|
||||
private fun drawBubble(canvas: Canvas, data: SortedMap<ZonedDateTime, Int>, maxValue: Int) {
|
||||
val data = data.toList()
|
||||
if (data.size <= selectedBar) return
|
||||
val bubbleBounds = bubbleBounds ?: return
|
||||
val graphBounds = graphBounds ?: return
|
||||
val d = data.toList()
|
||||
|
||||
if (d.size <= selectedBar) return
|
||||
canvas.apply {
|
||||
val center = graphBounds.left + selectedBar * (barWidth + barMargin) + barWidth * 0.5f
|
||||
val (t, v) = data[selectedBar]
|
||||
val (t, v) = d[selectedBar]
|
||||
val tformat = context.getString(
|
||||
R.string.prediction_time_colon,
|
||||
t.withZoneSameInstant(ZoneId.systemDefault()).format(timeFormat)
|
||||
@@ -273,6 +278,7 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs)
|
||||
}
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
val graphBounds = graphBounds ?: return super.onTouchEvent(event)
|
||||
val x = event.x.roundToInt()
|
||||
val y = event.y.roundToInt()
|
||||
if (graphBounds.contains(x, y) && event.action == MotionEvent.ACTION_DOWN) {
|
||||
@@ -290,6 +296,7 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs)
|
||||
}
|
||||
|
||||
private fun updateSelectedBar(x: Int) {
|
||||
val graphBounds = graphBounds ?: return
|
||||
val bar = (x - graphBounds.left) / (barWidth + barMargin)
|
||||
if (bar != selectedBar) {
|
||||
selectedBar = bar
|
||||
|
||||
@@ -121,6 +121,7 @@ private fun activeTint(
|
||||
}
|
||||
|
||||
@BindingAdapter("data")
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T> setRecyclerViewData(recyclerView: RecyclerView, items: List<T>?) {
|
||||
if (recyclerView.adapter is ListAdapter<*, *>) {
|
||||
(recyclerView.adapter as ListAdapter<T, *>).submitList(items)
|
||||
@@ -128,6 +129,7 @@ fun <T> setRecyclerViewData(recyclerView: RecyclerView, items: List<T>?) {
|
||||
}
|
||||
|
||||
@BindingAdapter("data")
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T> setRecyclerViewData(recyclerView: ViewPager2, items: List<T>?) {
|
||||
if (recyclerView.adapter is ListAdapter<*, *>) {
|
||||
(recyclerView.adapter as ListAdapter<T, *>).submitList(items)
|
||||
@@ -325,10 +327,10 @@ fun distance(meters: Number?): String? {
|
||||
}
|
||||
}
|
||||
|
||||
@InverseBindingAdapter(attribute = "app:values")
|
||||
@InverseBindingAdapter(attribute = "values")
|
||||
fun getRangeSliderValue(slider: RangeSlider) = slider.values
|
||||
|
||||
@BindingAdapter("app:valuesAttrChanged")
|
||||
@BindingAdapter("valuesAttrChanged")
|
||||
fun setRangeSliderListeners(slider: RangeSlider, attrChange: InverseBindingListener) {
|
||||
slider.addOnChangeListener { _, _, _ ->
|
||||
attrChange.onChange()
|
||||
@@ -348,7 +350,7 @@ fun colorEnabled(ctx: Context, enabled: Boolean): Int {
|
||||
return color
|
||||
}
|
||||
|
||||
@BindingAdapter("app:tint")
|
||||
@BindingAdapter("tint")
|
||||
fun setImageTintList(view: ImageView, @ColorInt color: Int) {
|
||||
view.imageTintList = ColorStateList.valueOf(color)
|
||||
}
|
||||
|
||||
@@ -89,12 +89,12 @@ class RangeSliderPreference(context: Context, attrs: AttributeSet) : Preference(
|
||||
slider.valueTo = valueTo
|
||||
stepSize?.let { slider.stepSize = it }
|
||||
|
||||
slider.addOnChangeListener { slider, value, fromUser ->
|
||||
slider.addOnChangeListener { slider, _, fromUser ->
|
||||
if (fromUser && (updatesContinuously || !dragging)) {
|
||||
syncValueInternal(slider)
|
||||
}
|
||||
}
|
||||
slider.setOnTouchListener { v, event ->
|
||||
slider.setOnTouchListener { _, event ->
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> dragging = true
|
||||
MotionEvent.ACTION_UP -> dragging = false
|
||||
|
||||
@@ -8,6 +8,8 @@ import android.location.Location
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.car2go.maps.model.LatLng
|
||||
import com.car2go.maps.model.LatLngBounds
|
||||
import net.vonforst.evmap.model.Coordinate
|
||||
import java.util.*
|
||||
import kotlin.math.*
|
||||
|
||||
/**
|
||||
@@ -48,7 +50,7 @@ fun distanceBetween(
|
||||
|
||||
|
||||
fun bearingBetween(startLat: Double, startLng: Double, endLat: Double, endLng: Double): Double {
|
||||
val dLon = Math.toRadians(-endLng) - Math.toRadians(-startLng)
|
||||
val dLon = Math.toRadians(endLng) - Math.toRadians(startLng)
|
||||
val originLat = Math.toRadians(startLat)
|
||||
val destinationLat = Math.toRadians(endLat)
|
||||
|
||||
@@ -111,4 +113,33 @@ fun Context.checkAnyLocationPermission() = ContextCompat.checkSelfPermission(
|
||||
fun Context.checkFineLocationPermission() = ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
fun Coordinate.formatDMS(): String {
|
||||
return "${dms(lat, false)}, ${dms(lng, true)}"
|
||||
}
|
||||
|
||||
fun Location.formatDMS(): String {
|
||||
return "${dms(latitude, false)}, ${dms(longitude, true)}"
|
||||
}
|
||||
|
||||
private fun dms(value: Double, lon: Boolean): String {
|
||||
val hemisphere = if (lon) {
|
||||
if (value >= 0) "E" else "W"
|
||||
} else {
|
||||
if (value >= 0) "N" else "S"
|
||||
}
|
||||
val d = abs(value)
|
||||
val degrees = floor(d).toInt()
|
||||
val minutes = floor((d - degrees) * 60).toInt()
|
||||
val seconds = ((d - degrees) * 60 - minutes) * 60
|
||||
return "%d°%02d'%02.1f\"%s".format(Locale.ENGLISH, degrees, minutes, seconds, hemisphere)
|
||||
}
|
||||
|
||||
fun Coordinate.formatDecimal(accuracy: Int = 6): String {
|
||||
return "%.${accuracy}f, %.${accuracy}f".format(Locale.ENGLISH, lat, lng)
|
||||
}
|
||||
|
||||
fun Location.formatDecimal(accuracy: Int = 6): String {
|
||||
return "%.${accuracy}f, %.${accuracy}f".format(Locale.ENGLISH, latitude, longitude)
|
||||
}
|
||||
@@ -166,7 +166,7 @@ class ChargepriceViewModel(
|
||||
)
|
||||
}
|
||||
}.filterNotNull()
|
||||
.sortedBy { it.chargepointPrices.first().price }
|
||||
.sortedBy { it.chargepointPrices.first().price ?: Double.MAX_VALUE }
|
||||
.sortedByDescending {
|
||||
prefs.chargepriceMyTariffsAll ||
|
||||
myTariffs != null && it.tariffId in myTariffs
|
||||
@@ -263,7 +263,8 @@ class ChargepriceViewModel(
|
||||
providerCustomerTariffs = prefs.chargepriceShowProviderCustomerTariffs,
|
||||
maxMonthlyFees = if (prefs.chargepriceNoBaseFee) 0.0 else null,
|
||||
currency = prefs.chargepriceCurrency,
|
||||
allowUnbalancedLoad = prefs.chargepriceAllowUnbalancedLoad
|
||||
allowUnbalancedLoad = prefs.chargepriceAllowUnbalancedLoad,
|
||||
showPriceUnavailable = true
|
||||
),
|
||||
relationships = if (!myTariffsAll) {
|
||||
Relationships(
|
||||
|
||||
@@ -16,7 +16,7 @@ import net.vonforst.evmap.model.FavoriteWithDetail
|
||||
import net.vonforst.evmap.storage.AppDatabase
|
||||
import net.vonforst.evmap.utils.distanceBetween
|
||||
|
||||
class FavoritesViewModel(application: Application, geApiKey: String) :
|
||||
class FavoritesViewModel(application: Application) :
|
||||
AndroidViewModel(application) {
|
||||
private var db = AppDatabase.getInstance(application)
|
||||
|
||||
@@ -69,7 +69,7 @@ class FavoritesViewModel(application: Application, geApiKey: String) :
|
||||
FavoritesListItem(
|
||||
favorite,
|
||||
totalAvailable(charger.id),
|
||||
charger.chargepoints.sumBy { it.count },
|
||||
charger.chargepoints.sumOf { it.count },
|
||||
location.value.let { loc ->
|
||||
if (loc == null) null else {
|
||||
distanceBetween(
|
||||
|
||||
@@ -61,9 +61,10 @@ class FilterViewModel(application: Application) : AndroidViewModel(application)
|
||||
prefs.filterStatus = FILTERS_CUSTOM
|
||||
}
|
||||
|
||||
suspend fun saveAsProfile(name: String) {
|
||||
suspend fun saveAsProfile(name: String): Boolean {
|
||||
// get or create profile
|
||||
var profileId = db.filterProfileDao().getProfileByName(name, prefs.dataSource)?.id
|
||||
|
||||
if (profileId == null) {
|
||||
profileId = db.filterProfileDao().getNewId(prefs.dataSource)
|
||||
db.filterProfileDao().insert(FilterProfile(name, prefs.dataSource, profileId))
|
||||
@@ -81,6 +82,8 @@ class FilterViewModel(application: Application) : AndroidViewModel(application)
|
||||
|
||||
// set selected profile
|
||||
prefs.filterStatus = profileId
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun deleteCurrentProfile() {
|
||||
@@ -89,4 +92,8 @@ class FilterViewModel(application: Application) : AndroidViewModel(application)
|
||||
prefs.filterStatus = FILTERS_DISABLED
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resetValues() {
|
||||
db.filterValueDao().deleteFilterValuesForProfile(FILTERS_CUSTOM, prefs.dataSource)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import androidx.lifecycle.*
|
||||
import com.car2go.maps.AnyMap
|
||||
import com.car2go.maps.model.LatLng
|
||||
import com.car2go.maps.model.LatLngBounds
|
||||
import com.mahc.custombottomsheetbehavior.BottomSheetBehaviorGoogleMapsLike
|
||||
import com.squareup.moshi.JsonDataException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -71,6 +73,21 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
|
||||
state.getLiveData("bottomSheetState")
|
||||
}
|
||||
|
||||
val bottomSheetExpanded = MediatorLiveData<Boolean>().apply {
|
||||
addSource(bottomSheetState) {
|
||||
when (it) {
|
||||
BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED,
|
||||
BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN -> {
|
||||
value = false
|
||||
}
|
||||
BottomSheetBehaviorGoogleMapsLike.STATE_EXPANDED,
|
||||
BottomSheetBehaviorGoogleMapsLike.STATE_ANCHOR_POINT -> {
|
||||
value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val mapPosition: MutableLiveData<MapPosition> by lazy {
|
||||
state.getLiveData("mapPosition")
|
||||
}
|
||||
@@ -213,7 +230,7 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
|
||||
}
|
||||
}
|
||||
|
||||
val predictionApi = FronyxApi.create(application.getString(R.string.fronyx_key))
|
||||
val predictionApi = FronyxApi(application.getString(R.string.fronyx_key))
|
||||
|
||||
val prediction: LiveData<Resource<List<FronyxEvseIdResponse>>> by lazy {
|
||||
availability.switchMap { av ->
|
||||
@@ -233,14 +250,13 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
|
||||
).any { filtered.contains(it) }
|
||||
} ?: true
|
||||
}.flatMap { it.value }
|
||||
|
||||
try {
|
||||
val result = allEvseIds.map {
|
||||
predictionApi.getPredictionsForEvseId(it)
|
||||
val result = predictionApi.getPredictionsForEvseIds(allEvseIds)
|
||||
if (result.size == allEvseIds.size) {
|
||||
emit(Resource.success(result))
|
||||
} else {
|
||||
emit(Resource.error("not all EVSEIDs found", null))
|
||||
}
|
||||
|
||||
emit(Resource.success(result))
|
||||
println(result)
|
||||
} catch (e: IOException) {
|
||||
emit(Resource.error(e.message, null))
|
||||
e.printStackTrace()
|
||||
@@ -250,6 +266,10 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
|
||||
} catch (e: AvailabilityDetectorException) {
|
||||
emit(Resource.error(e.message, null))
|
||||
e.printStackTrace()
|
||||
} catch (e: JsonDataException) {
|
||||
// malformed JSON response from fronyx API
|
||||
emit(Resource.error(e.message, null))
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
} ?: liveData { emit(Resource.success(null)) }
|
||||
@@ -472,8 +492,6 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
|
||||
chargepointsInternal?.let { chargepoints.removeSource(it) }
|
||||
chargepointsInternal = result
|
||||
chargepoints.addSource(result) {
|
||||
chargepoints.value = it
|
||||
|
||||
val apiId = apiId.value
|
||||
when (apiId) {
|
||||
"going_electric" -> {
|
||||
@@ -506,6 +524,8 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
|
||||
filteredChargeCards.value = null
|
||||
}
|
||||
}
|
||||
|
||||
chargepoints.value = it
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import java.util.concurrent.atomic.AtomicBoolean
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
inline fun <VM : ViewModel> viewModelFactory(crossinline f: () -> VM) =
|
||||
object : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(aClass: Class<T>): T = f() as T
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = f() as T
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@@ -106,7 +106,8 @@ fun <T> throttleLatest(
|
||||
}
|
||||
}
|
||||
|
||||
public suspend fun <T> LiveData<T>.await(): T {
|
||||
@ExperimentalCoroutinesApi
|
||||
suspend fun <T> LiveData<T>.await(): T {
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val observer = object : Observer<T> {
|
||||
override fun onChanged(value: T?) {
|
||||
@@ -124,7 +125,8 @@ public suspend fun <T> LiveData<T>.await(): T {
|
||||
}
|
||||
}
|
||||
|
||||
public suspend fun <T> LiveData<Resource<T>>.awaitFinished(): Resource<T> {
|
||||
@ExperimentalCoroutinesApi
|
||||
suspend fun <T> LiveData<Resource<T>>.awaitFinished(): Resource<T> {
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val observer = object : Observer<Resource<T>> {
|
||||
override fun onChanged(value: Resource<T>) {
|
||||
|
||||
10
app/src/main/res/drawable/ic_filter_no.xml
Normal file
10
app/src/main/res/drawable/ic_filter_no.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportHeight="24"
|
||||
android:viewportWidth="24"
|
||||
android:width="24dp"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M10.83,8H21V6H8.83L10.83,8zM15.83,13H18v-2h-4.17L15.83,13zM14,16.83V18h-4v-2h3.17l-3,-3H6v-2h2.17l-3,-3H3V6h0.17L1.39,4.22l1.41,-1.41l18.38,18.38l-1.41,1.41L14,16.83z" />
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/ic_manage_filter_profiles.xml
Normal file
10
app/src/main/res/drawable/ic_manage_filter_profiles.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportHeight="24"
|
||||
android:viewportWidth="24"
|
||||
android:width="24dp"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M3,10h11v2H3V10zM3,8h11V6H3V8zM3,16h7v-2H3V16zM18.01,12.87l0.71,-0.71c0.39,-0.39 1.02,-0.39 1.41,0l0.71,0.71c0.39,0.39 0.39,1.02 0,1.41l-0.71,0.71L18.01,12.87zM17.3,13.58l-5.3,5.3V21h2.12l5.3,-5.3L17.3,13.58z" />
|
||||
</vector>
|
||||
@@ -97,15 +97,15 @@
|
||||
android:id="@+id/txtName"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="30dp"
|
||||
android:ellipsize="end"
|
||||
android:hyphenationFrequency="normal"
|
||||
android:maxLines="@{expanded ? 3 : 1}"
|
||||
android:text="@{charger.data.name}"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
|
||||
app:layout_constrainedWidth="true"
|
||||
app:layout_constraintEnd_toStartOf="@+id/txtAvailability"
|
||||
app:layout_constraintEnd_toStartOf="@+id/imgFaultReport"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintHorizontal_chainStyle="packed"
|
||||
app:layout_constraintStart_toStartOf="@+id/guideline"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="Parkhaus" />
|
||||
@@ -379,8 +379,8 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:text="@{predictionDescription}"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
app:goneUnless="@{predictionGraph != null}"
|
||||
app:layout_constraintBaseline_toBaselineOf="@+id/textView8"
|
||||
app:layout_constraintEnd_toStartOf="@+id/btnPredictionHelp"
|
||||
@@ -422,21 +422,21 @@
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:adjustViewBounds="true"
|
||||
android:scaleType="fitCenter"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
app:tint="@color/logo_tint_night"
|
||||
android:scaleType="fitCenter"
|
||||
app:goneUnless="@{predictionGraph != null}"
|
||||
app:layout_constraintEnd_toStartOf="@+id/guideline2"
|
||||
app:layout_constraintTop_toBottomOf="@+id/prediction"
|
||||
app:srcCompat="@drawable/ic_powered_by_fronyx" />
|
||||
app:srcCompat="@drawable/ic_powered_by_fronyx"
|
||||
app:tint="@color/logo_tint_night" />
|
||||
|
||||
<View
|
||||
android:id="@+id/divider1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginTop="8dp"
|
||||
app:goneUnless="@{predictionGraph != null}"
|
||||
android:background="?android:attr/listDivider"
|
||||
app:goneUnless="@{predictionGraph != null}"
|
||||
app:layout_constraintTop_toBottomOf="@+id/imgPredictionSource" />
|
||||
|
||||
<ImageView
|
||||
@@ -445,10 +445,11 @@
|
||||
android:layout_height="18dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginTop="2dp"
|
||||
android:layout_marginBottom="2dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:contentDescription="@string/verified"
|
||||
app:goneUnless="@{ charger.data.verified }"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/txtName"
|
||||
app:layout_constraintEnd_toStartOf="@+id/txtAvailability"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toEndOf="@+id/imgFaultReport"
|
||||
app:layout_constraintTop_toTopOf="@+id/txtName"
|
||||
app:srcCompat="@drawable/ic_verified"
|
||||
@@ -460,12 +461,11 @@
|
||||
android:id="@+id/imgFaultReport"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginTop="2dp"
|
||||
android:layout_marginBottom="2dp"
|
||||
android:contentDescription="@string/fault_report"
|
||||
app:goneUnless="@{ charger.data.faultReport != null }"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/txtName"
|
||||
app:layout_constraintEnd_toStartOf="@+id/imgVerified"
|
||||
app:layout_constraintStart_toEndOf="@+id/txtName"
|
||||
app:layout_constraintTop_toTopOf="@+id/txtName"
|
||||
app:srcCompat="@drawable/ic_map_marker_fault"
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
app:chargeCards="@{vm.chargeCardMap}"
|
||||
app:filteredChargeCards="@{vm.filteredChargeCards}"
|
||||
app:distance="@{vm.chargerDistance}"
|
||||
app:expanded="@{vm.bottomSheetState != BottomSheetBehaviorGoogleMapsLike.STATE_COLLAPSED && vm.bottomSheetState != BottomSheetBehaviorGoogleMapsLike.STATE_HIDDEN}"
|
||||
app:expanded="@{vm.bottomSheetExpanded}"
|
||||
app:apiName="@{vm.apiName}" />
|
||||
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
@@ -230,6 +230,7 @@
|
||||
android:theme="@style/ThemeOverlay.Material3.Dark.ActionBar" />
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
style="@style/Widget.Material3.FloatingActionButton.Small.Surface"
|
||||
android:id="@+id/fab_layers"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
@@ -239,9 +240,7 @@
|
||||
android:layout_marginEnd="20dp"
|
||||
android:layout_marginTop="@dimen/layers_fab_top_padding"
|
||||
app:tint="?android:colorControlNormal"
|
||||
app:backgroundTint="?android:colorBackground"
|
||||
app:borderWidth="0dp"
|
||||
app:fabSize="mini"
|
||||
app:srcCompat="@drawable/ic_layers"
|
||||
app:layout_behavior="@string/hide_on_scroll_fab_behavior"
|
||||
android:theme="@style/NoElevationOverlay" />
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:gravity="end"
|
||||
android:text="@{String.format(@string/charge_price_format, item.chargepointPrices.get(0).price, BindingAdaptersKt.currency(item.currency))}"
|
||||
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"
|
||||
@@ -125,8 +125,8 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:gravity="end"
|
||||
android:text="@{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))}"
|
||||
app:goneUnless="@{item.chargepointPrices.get(0).price > 0}"
|
||||
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"
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="38dp"
|
||||
android:layout_marginTop="38dp"
|
||||
android:text="@{String.format("× %d", item.chargepoint.count)}"
|
||||
android:text="@{String.format("\u00D7 %d", item.chargepoint.count)}"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
app:layout_constraintStart_toStartOf="@+id/imageView"
|
||||
app:layout_constraintTop_toTopOf="@+id/imageView"
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<item
|
||||
android:id="@+id/menu_reset"
|
||||
android:title="@string/menu_reset"
|
||||
android:icon="@drawable/ic_filter_no"
|
||||
app:showAsAction="ifRoom" />
|
||||
|
||||
<item
|
||||
android:id="@+id/menu_save_profile"
|
||||
android:title="@string/menu_save_profile"
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
<item
|
||||
android:id="@+id/menu_edit_filters"
|
||||
android:title="@string/menu_edit_filters"
|
||||
android:menuCategory="secondary" />
|
||||
android:menuCategory="secondary"
|
||||
android:icon="@drawable/ic_edit" />
|
||||
<item
|
||||
android:id="@+id/menu_manage_filter_profiles"
|
||||
android:title="@string/menu_manage_filter_profiles"
|
||||
android:menuCategory="secondary" />
|
||||
android:menuCategory="secondary"
|
||||
android:icon="@drawable/ic_manage_filter_profiles" />
|
||||
</menu>
|
||||
@@ -143,13 +143,14 @@
|
||||
<string name="category_caravan_site">Wohnmobilstellplatz</string>
|
||||
<string name="menu_apply">Filter anwenden</string>
|
||||
<string name="menu_save_profile">Als Profil speichern</string>
|
||||
<string name="menu_reset">Filter zurücksetzen</string>
|
||||
<string name="no_filters">Keine Filter</string>
|
||||
<string name="filter_custom">Verändertes Filterprofil</string>
|
||||
<string name="filter_favorites">Favoriten</string>
|
||||
<string name="reorder">Reihenfolge ändern</string>
|
||||
<string name="delete">Löschen</string>
|
||||
<string name="save_as_profile">Als Profil speichern</string>
|
||||
<string name="save_profile_enter_name">Geben Sie den Namen des Filterprofils ein:</string>
|
||||
<string name="save_profile_enter_name">Gib den Namen des Filterprofils ein:</string>
|
||||
<string name="filterprofiles_empty_state">Du hast keine Filterprofile gespeichert</string>
|
||||
<string name="welcome_to_evmap">Willkommen bei EVMap</string>
|
||||
<string name="welcome_1">Finde Ladestationen für Elektroautos in deiner Nähe</string>
|
||||
@@ -196,6 +197,7 @@
|
||||
<string name="chargeprice_battery_range_to">bis</string>
|
||||
<string name="chargeprice_stats">(%1$.0f kWh, ca. %2$s, ⌀ %3$.0f kW)</string>
|
||||
<string name="chargeprice_vehicle">Fahrzeug</string>
|
||||
<string name="chargeprice_price_not_available">Preis nicht verfügbar</string>
|
||||
<string name="edit_on_goingelectric_info">Logge dich zuerst bei GoingElectric.de ein, falls hier nur eine leere Seite erscheint</string>
|
||||
<string name="close">Schließen</string>
|
||||
<string name="chargeprice_title">Preise</string>
|
||||
@@ -282,4 +284,15 @@
|
||||
<string name="pref_prediction_enabled_summary">für unterstützte Ladestationen\n(momentan nur Schnellader in Deutschland)</string>
|
||||
<string name="prediction_only">(nur %s)</string>
|
||||
<string name="prediction_dc_plugs_only">DC-Anschlüsse</string>
|
||||
<string name="data_source_switched_to">Datenquelle zu %s umgeschaltet</string>
|
||||
<string name="pref_applink_associate">Unterstützte Links öffnen</string>
|
||||
<string name="pref_applink_associate_summary">von goingelectric.de und openchargemap.org</string>
|
||||
<string name="chargeprice_header_my_tariffs">Meine Tarife</string>
|
||||
<string name="chargeprice_header_other_tariffs">Andere Tarife</string>
|
||||
<string name="developer_mode_enabled">Entwicklermodus aktiviert</string>
|
||||
<string name="developer_options">Entwicklereinstellungen</string>
|
||||
<string name="disable_developer_mode">Entwicklermodus deaktivieren</string>
|
||||
<string name="developer_mode_disabled">Entwicklermodus deaktiviert</string>
|
||||
<string name="gps">GPS</string>
|
||||
<string name="compass">Kompass</string>
|
||||
</resources>
|
||||
@@ -284,4 +284,16 @@
|
||||
<string name="pref_prediction_enabled">Vis bruksprognoser</string>
|
||||
<string name="pref_prediction_enabled_summary">for støttede ladere
|
||||
\n(foreløpig kun for likestrøm i Tyskland)</string>
|
||||
<string name="chargeprice_price_not_available">Pris ikke tilgjengelig</string>
|
||||
<string name="developer_mode_disabled">Utviklermodus avslått</string>
|
||||
<string name="gps">GPS</string>
|
||||
<string name="compass">Kompass</string>
|
||||
<string name="pref_applink_associate">Åpne støttede lenker</string>
|
||||
<string name="pref_applink_associate_summary">fra goingelectric.de og openchargemap.org</string>
|
||||
<string name="chargeprice_header_other_tariffs">Andre ladeabonnementer</string>
|
||||
<string name="disable_developer_mode">Skru av utviklermodus</string>
|
||||
<string name="chargeprice_header_my_tariffs">Mine ladeabonnementer</string>
|
||||
<string name="developer_options">Utvikleralternativer</string>
|
||||
<string name="data_source_switched_to">Datakilde byttet til %s</string>
|
||||
<string name="developer_mode_enabled">Utviklermodus påslått</string>
|
||||
</resources>
|
||||
@@ -13,6 +13,15 @@
|
||||
<string name="pref_language_de">Deutsch</string>
|
||||
<string name="pref_language_fr">Français</string>
|
||||
<string name="pref_language_nb_rNO">Norsk Bokmål</string>
|
||||
<string name="about_contributors_list">Danilo Bargen\nAltonss\nAllan Nordhøy\nLicaon_Kter\npt2121\nnautilusx</string>
|
||||
<string name="about_contributors_list">
|
||||
Danilo Bargen\n
|
||||
Altonss\n
|
||||
Allan Nordhøy\n
|
||||
Maximilian Goldschmidt\n
|
||||
Licaon_Kter\n
|
||||
pt2121\n
|
||||
nautilusx
|
||||
</string>
|
||||
<string name="hide_on_scroll_fab_behavior">net.vonforst.evmap.ui.HideOnScrollFabBehavior</string>
|
||||
<string name="paypal_link" translatable="false">https://paypal.me/johan98</string>
|
||||
</resources>
|
||||
@@ -142,6 +142,7 @@
|
||||
<string name="category_caravan_site">Caravan site</string>
|
||||
<string name="menu_apply">Apply filters</string>
|
||||
<string name="menu_save_profile">Save as profile</string>
|
||||
<string name="menu_reset">Reset filter settings</string>
|
||||
<string name="no_filters">No filters</string>
|
||||
<string name="filter_custom">Modified filter</string>
|
||||
<string name="filter_favorites">Favorites</string>
|
||||
@@ -195,6 +196,7 @@
|
||||
<string name="chargeprice_battery_range_to">to</string>
|
||||
<string name="chargeprice_stats">(%1$.0f kWh, approx. %2$s, ⌀ %3$.0f kW)</string>
|
||||
<string name="chargeprice_vehicle">Vehicle</string>
|
||||
<string name="chargeprice_price_not_available">Price not available</string>
|
||||
<string name="pref_chargeprice_show_provider_customer_tariffs_summary">Utility companies sometimes offer special plans for their customers</string>
|
||||
<string name="close">Close</string>
|
||||
<string name="chargeprice_title">Prices</string>
|
||||
@@ -281,4 +283,15 @@
|
||||
<string name="pref_prediction_enabled_summary">for supported chargers\n(currently only DC in Germany)</string>
|
||||
<string name="prediction_only">(%s only)</string>
|
||||
<string name="prediction_dc_plugs_only">DC plugs</string>
|
||||
<string name="data_source_switched_to">Data source switched to %s</string>
|
||||
<string name="pref_applink_associate">Open supported links</string>
|
||||
<string name="pref_applink_associate_summary">from goingelectric.de and openchargemap.org</string>
|
||||
<string name="chargeprice_header_my_tariffs">My plans</string>
|
||||
<string name="chargeprice_header_other_tariffs">Other plans</string>
|
||||
<string name="developer_mode_enabled">Developer mode enabled</string>
|
||||
<string name="developer_options">Developer options</string>
|
||||
<string name="disable_developer_mode">Disable developer mode</string>
|
||||
<string name="developer_mode_disabled">Developer mode disabled</string>
|
||||
<string name="gps">GPS</string>
|
||||
<string name="compass">Compass</string>
|
||||
</resources>
|
||||
|
||||
@@ -28,4 +28,8 @@
|
||||
android:summaryOn="@string/pref_navigate_use_maps_on"
|
||||
android:summaryOff="@string/pref_navigate_use_maps_off"
|
||||
android:defaultValue="true" />
|
||||
<Preference
|
||||
android:key="applink_associate"
|
||||
android:title="@string/pref_applink_associate"
|
||||
android:summary="@string/pref_applink_associate_summary" />
|
||||
</PreferenceScreen>
|
||||
@@ -9,7 +9,7 @@
|
||||
(e.g. in the debug version). -->
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
android:targetPackage="${applicationId}"
|
||||
android:targetPackage="net.vonforst.evmap"
|
||||
android:targetClass="net.vonforst.evmap.MapsActivity">
|
||||
<extra
|
||||
android:name="favorites"
|
||||
|
||||
@@ -20,7 +20,7 @@ class FronyxApiTest {
|
||||
webServer.start()
|
||||
|
||||
val apikey = ""
|
||||
fronyx = FronyxApi.create(
|
||||
fronyx = FronyxApi(
|
||||
apikey,
|
||||
webServer.url("/").toString()
|
||||
)
|
||||
@@ -36,6 +36,14 @@ class FronyxApiTest {
|
||||
val id = segments[2]
|
||||
return okResponse("/fronyx/${id.replace("*", "_")}.json")
|
||||
}
|
||||
"predictions/evses" -> {
|
||||
val ids = request.requestUrl!!.queryParameter("evseIds")!!.split(",")
|
||||
return okResponse(
|
||||
"/fronyx/${
|
||||
ids.map { it.replace("*", "_") }.joinToString(",")
|
||||
}.json"
|
||||
)
|
||||
}
|
||||
else -> return notFoundResponse
|
||||
}
|
||||
}
|
||||
@@ -43,7 +51,7 @@ class FronyxApiTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun apiTest() {
|
||||
fun apiTestSingle() {
|
||||
val evseId = "DE*ION*E202102"
|
||||
|
||||
runBlocking {
|
||||
@@ -57,4 +65,25 @@ class FronyxApiTest {
|
||||
assertEquals(FronyxStatus.AVAILABLE, result.predictions[0].status)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun apiTestMultiple() {
|
||||
val evseIds = listOf("DE*ION*E202101", "DE*ION*E202102")
|
||||
|
||||
runBlocking {
|
||||
val results = fronyx.getPredictionsForEvseIds(evseIds)
|
||||
results.forEachIndexed { i, result ->
|
||||
assertEquals(result.evseId, evseIds[i])
|
||||
assertEquals(25, result.predictions.size)
|
||||
assertEquals(
|
||||
ZonedDateTime.of(2022, 11, 16, 18, 0, 0, 0, ZoneOffset.UTC),
|
||||
result.predictions[0].timestamp
|
||||
)
|
||||
assertEquals(
|
||||
if (i == 0) FronyxStatus.UNAVAILABLE else FronyxStatus.AVAILABLE,
|
||||
result.predictions[0].status
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package net.vonforst.evmap.model
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ChargersModelTest {
|
||||
@Test
|
||||
fun testAddressToString() {
|
||||
assertEquals("Berlin", Address("Berlin", null, null, null).toString())
|
||||
assertEquals("12345 Berlin", Address("Berlin", null, "12345", null).toString())
|
||||
assertEquals(
|
||||
"Pariser Platz 1, Berlin",
|
||||
Address("Berlin", null, null, "Pariser Platz 1").toString()
|
||||
)
|
||||
assertEquals(
|
||||
"Pariser Platz 1, 12345 Berlin",
|
||||
Address("Berlin", null, "12345", "Pariser Platz 1").toString()
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
214
app/src/test/resources/fronyx/DE_ION_E202101,DE_ION_E202102.json
Normal file
214
app/src/test/resources/fronyx/DE_ION_E202101,DE_ION_E202102.json
Normal file
@@ -0,0 +1,214 @@
|
||||
[
|
||||
{
|
||||
"evseId": "DE*ION*E202101",
|
||||
"locationId": "DE-ION-03e2876e-0fd0-4e9d-abc1-d2caa1473947",
|
||||
"predictions": [
|
||||
{
|
||||
"timestamp": "2022-11-16T18:00:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T18:15:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T18:30:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T18:45:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T19:00:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T19:15:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T19:30:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T19:45:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T20:00:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T20:15:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T20:30:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T20:45:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T21:00:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T21:15:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T21:30:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T21:45:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T22:00:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T22:15:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T22:30:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T22:45:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T23:00:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T23:15:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T23:30:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T23:45:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-17T00:00:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"evseId": "DE*ION*E202102",
|
||||
"locationId": "DE-ION-03e2876e-0fd0-4e9d-abc1-d2caa1473947",
|
||||
"predictions": [
|
||||
{
|
||||
"timestamp": "2022-11-16T18:00:00.000Z",
|
||||
"status": "AVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T18:15:00.000Z",
|
||||
"status": "AVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T18:30:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T18:45:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T19:00:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T19:15:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T19:30:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T19:45:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T20:00:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T20:15:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T20:30:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T20:45:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T21:00:00.000Z",
|
||||
"status": "UNAVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T21:15:00.000Z",
|
||||
"status": "AVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T21:30:00.000Z",
|
||||
"status": "AVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T21:45:00.000Z",
|
||||
"status": "AVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T22:00:00.000Z",
|
||||
"status": "AVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T22:15:00.000Z",
|
||||
"status": "AVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T22:30:00.000Z",
|
||||
"status": "AVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T22:45:00.000Z",
|
||||
"status": "AVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T23:00:00.000Z",
|
||||
"status": "AVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T23:15:00.000Z",
|
||||
"status": "AVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T23:30:00.000Z",
|
||||
"status": "AVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-16T23:45:00.000Z",
|
||||
"status": "AVAILABLE"
|
||||
},
|
||||
{
|
||||
"timestamp": "2022-11-17T00:00:00.000Z",
|
||||
"status": "AVAILABLE"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
40
app/src/testGoogle/java/net/vonforst/evmap/auto/UtilsTest.kt
Normal file
40
app/src/testGoogle/java/net/vonforst/evmap/auto/UtilsTest.kt
Normal file
@@ -0,0 +1,40 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class UtilsTest {
|
||||
@Test
|
||||
fun testPaginate() {
|
||||
var (nSingle, nFirst, nOther, nLast) = listOf(6, 5, 4, 5)
|
||||
for (i in 0..30) {
|
||||
paginateTest(i, nSingle, nFirst, nOther, nLast)
|
||||
}
|
||||
nSingle = 4; nFirst = 4; nOther = 6; nLast = 6
|
||||
for (i in 0..30) {
|
||||
paginateTest(i, nSingle, nFirst, nOther, nLast)
|
||||
}
|
||||
}
|
||||
|
||||
private fun paginateTest(
|
||||
i: Int,
|
||||
nSingle: Int,
|
||||
nFirst: Int,
|
||||
nOther: Int,
|
||||
nLast: Int
|
||||
) {
|
||||
val list = (0..i).toList()
|
||||
val paginated = list.paginate(nSingle, nFirst, nOther, nLast)
|
||||
assertEquals(list, paginated.flatten())
|
||||
assert(paginated.all { it.isNotEmpty() })
|
||||
if (paginated.size == 1) {
|
||||
assert(paginated.first().size <= nSingle)
|
||||
} else {
|
||||
assert(paginated.first().size == nFirst)
|
||||
for (j in 1 until paginated.size - 1) {
|
||||
assert(paginated[j].size == nOther)
|
||||
}
|
||||
assert(paginated.last().size <= nLast)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,19 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.7.10'
|
||||
ext.kotlin_version = '1.7.21'
|
||||
ext.about_libs_version = '8.9.4'
|
||||
ext.nav_version = '2.5.2'
|
||||
ext.nav_version = '2.5.3'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:7.3.0'
|
||||
classpath 'com.android.tools.build:gradle:7.4.0'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:$about_libs_version"
|
||||
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
|
||||
classpath "de.timfreiheit.resourceplaceholders:placeholders:0.4"
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
|
||||
9
fastlane/metadata/android/de-DE/changelogs/140.txt
Normal file
9
fastlane/metadata/android/de-DE/changelogs/140.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
Verbesserungen:
|
||||
- Preisvergleich: Auch Tarife mit fehlenden Preisdaten anzeigen
|
||||
- Links von openchargemap.org können in EVMap geöffnet werden
|
||||
- Android Auto: Ladegeschwindigkeit bei der Detailansicht optimiert
|
||||
- Android Auto: Mehrseitige Ansichten für Filter und Filterprofile (falls nötig)
|
||||
|
||||
Fehler behoben:
|
||||
- diverse kleine Darstellungsfehler behoben
|
||||
- Abstürze behoben
|
||||
2
fastlane/metadata/android/de-DE/changelogs/142.txt
Normal file
2
fastlane/metadata/android/de-DE/changelogs/142.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Fehler behoben:
|
||||
- Links zur Datenquelle werden im Browser geöffnet auch wenn EVMap als Standard für Links von GoingElectric/OpenChargeMap gesetzt ist
|
||||
8
fastlane/metadata/android/de-DE/changelogs/146.txt
Normal file
8
fastlane/metadata/android/de-DE/changelogs/146.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
Verbesserungen:
|
||||
- Laden der Verfügbarkeitsprognosen beschleunigt
|
||||
- Android Auto: "Meine Tarife" im Preisvergleich hervorheben
|
||||
|
||||
Fehler behoben:
|
||||
- Android Automotive: Aktualisieren-Button fehlte
|
||||
- Darstellungsfehler nach dem Scrollen der Detailansicht behoben
|
||||
- Abstürze behoben
|
||||
9
fastlane/metadata/android/de-DE/changelogs/148.txt
Normal file
9
fastlane/metadata/android/de-DE/changelogs/148.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
Verbesserungen:
|
||||
- Laden der Verfügbarkeitsprognosen beschleunigt
|
||||
- Android Auto: "Meine Tarife" im Preisvergleich hervorheben
|
||||
|
||||
Fehler behoben:
|
||||
- Android Automotive: Aktualisieren-Button fehlte
|
||||
- Android Auto: Klick auf Suchergebnis funktioniert manchmal nicht
|
||||
- Darstellungsfehler nach dem Scrollen der Detailansicht behoben
|
||||
- Abstürze behoben
|
||||
5
fastlane/metadata/android/de-DE/changelogs/156.txt
Normal file
5
fastlane/metadata/android/de-DE/changelogs/156.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
Verbesserungen:
|
||||
- Android Auto: Suchbutton während der Fahrt freigeschaltet (ggf. ohne Tastatur)
|
||||
|
||||
Fehler behoben:
|
||||
- Abstürze behoben
|
||||
7
fastlane/metadata/android/de-DE/changelogs/158.txt
Normal file
7
fastlane/metadata/android/de-DE/changelogs/158.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
Verbesserungen:
|
||||
- Neuer Knopf zum Zurücksetzen der Filtereinstellungen
|
||||
- Filtermenü mit neuen Icons
|
||||
- Übersetzungen aktualisiert
|
||||
|
||||
Fehler behoben:
|
||||
- Abstürze behoben
|
||||
2
fastlane/metadata/android/de-DE/changelogs/160.txt
Normal file
2
fastlane/metadata/android/de-DE/changelogs/160.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Fehler behoben:
|
||||
- Abstürze behoben
|
||||
9
fastlane/metadata/android/en-US/changelogs/140.txt
Normal file
9
fastlane/metadata/android/en-US/changelogs/140.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
Improvements:
|
||||
- Price comparison: Also show plans with unknown pricing
|
||||
- Links from openchargemap.org can be opened with EVMap
|
||||
- Android Auto: Improved loading speed for detail view
|
||||
- Android Auto: Multi-page views for filters and filter profiles (if necessary)
|
||||
|
||||
Bugfixes:
|
||||
- fixed multiple minor display bugs
|
||||
- fixed crashes
|
||||
2
fastlane/metadata/android/en-US/changelogs/142.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/142.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Bugfixes:
|
||||
- Open links to data source in browser even if EVMap is set to open GoingElectric/OpenChargeMap links by default
|
||||
8
fastlane/metadata/android/en-US/changelogs/146.txt
Normal file
8
fastlane/metadata/android/en-US/changelogs/146.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
Improvements:
|
||||
- Faster loading of availability prediction
|
||||
- Android Auto: highlight "my plans" in price comparison
|
||||
|
||||
Bugfixes:
|
||||
- Android Automotive: refresh button was missing
|
||||
- fixed visual bug after scrolling detail view
|
||||
- fixed crashes
|
||||
9
fastlane/metadata/android/en-US/changelogs/148.txt
Normal file
9
fastlane/metadata/android/en-US/changelogs/148.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
Improvements:
|
||||
- Faster loading of availability prediction
|
||||
- Android Auto: highlight "my plans" in price comparison
|
||||
|
||||
Bugfixes:
|
||||
- Android Automotive: refresh button was missing
|
||||
- Android Auto: Clicking search result sometimes not working
|
||||
- fixed visual bug after scrolling detail view
|
||||
- fixed crashes
|
||||
5
fastlane/metadata/android/en-US/changelogs/156.txt
Normal file
5
fastlane/metadata/android/en-US/changelogs/156.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
Improvements:
|
||||
- Android Auto: Search button available while driving (possibly without keyboard)
|
||||
|
||||
Bugs fixed:
|
||||
- Fixed crashes
|
||||
7
fastlane/metadata/android/en-US/changelogs/158.txt
Normal file
7
fastlane/metadata/android/en-US/changelogs/158.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
Improvements:
|
||||
- New button to reset filter setting
|
||||
- Filter menu with new icons
|
||||
- Updated translations
|
||||
|
||||
Bugfixes:
|
||||
- Fixed crashes
|
||||
2
fastlane/metadata/android/en-US/changelogs/160.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/160.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Bugfixes:
|
||||
- Fixed crashes
|
||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,6 @@
|
||||
#Sat Aug 06 15:33:46 CEST 2022
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
|
||||
distributionPath=wrapper/dists
|
||||
zipStorePath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
Reference in New Issue
Block a user