mirror of
https://github.com/ev-map/EVMap.git
synced 2025-12-25 16:17:45 -05:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1134499532 | ||
|
|
0417ade802 | ||
|
|
8fafabf6a8 | ||
|
|
1b3c35e94f | ||
|
|
23a3adc500 | ||
|
|
16c2dcc938 | ||
|
|
f322974e52 | ||
|
|
50ae2123e9 | ||
|
|
72894399f6 | ||
|
|
77014d754f | ||
|
|
66dbd6426f | ||
|
|
e4127f4a56 | ||
|
|
f9bf8b80f7 | ||
|
|
67eeb47d5f | ||
|
|
3c6a7cd536 | ||
|
|
31e3509369 | ||
|
|
b03f765216 | ||
|
|
9222dec613 | ||
|
|
71c36fbc8f | ||
|
|
830477e664 | ||
|
|
3ce91a9c50 | ||
|
|
a3b2b94b25 |
@@ -19,8 +19,8 @@ android {
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 34
|
||||
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
|
||||
versionCode 198
|
||||
versionName "1.6.8"
|
||||
versionCode 202
|
||||
versionName "1.7.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
resConfigs supportedLocales.split(',')
|
||||
@@ -170,7 +170,7 @@ configurations {
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'androidx.core:core-ktx:1.10.1'
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.core:core-splashscreen:1.0.1'
|
||||
implementation "androidx.activity:activity-ktx:1.7.2"
|
||||
implementation "androidx.fragment:fragment-ktx:1.6.1"
|
||||
@@ -202,7 +202,7 @@ dependencies {
|
||||
implementation 'com.github.romandanylyk:PageIndicatorView:b1bad589b5'
|
||||
|
||||
// Android Auto
|
||||
def carAppVersion = '1.4.0-beta01'
|
||||
def carAppVersion = '1.4.0-beta02'
|
||||
implementation "androidx.car.app:app:$carAppVersion"
|
||||
normalImplementation "androidx.car.app:app-projected:$carAppVersion"
|
||||
automotiveImplementation "androidx.car.app:app-automotive:$carAppVersion"
|
||||
@@ -235,7 +235,7 @@ dependencies {
|
||||
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
|
||||
|
||||
// viewmodel library
|
||||
def lifecycle_version = "2.6.1"
|
||||
def lifecycle_version = "2.6.2"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
|
||||
|
||||
@@ -253,7 +253,6 @@ dependencies {
|
||||
|
||||
// ACRA (crash reporting)
|
||||
def acraVersion = "5.11.1"
|
||||
implementation("ch.acra:acra-mail:$acraVersion")
|
||||
implementation("ch.acra:acra-http:$acraVersion")
|
||||
implementation("ch.acra:acra-dialog:$acraVersion")
|
||||
implementation("ch.acra:acra-limiter:$acraVersion")
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
<uses-permission android:name="androidx.car.app.MAP_TEMPLATES" />
|
||||
<uses-permission android:name="com.google.android.gms.permission.CAR_FUEL" />
|
||||
<uses-permission android:name="com.google.android.gms.permission.CAR_SPEED" />
|
||||
@@ -289,6 +290,10 @@
|
||||
android:resource="@xml/shortcuts" />
|
||||
</activity>
|
||||
|
||||
<activity android:name=".auto.OAuthLoginActivity">
|
||||
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
android:enabled="false"
|
||||
|
||||
@@ -11,7 +11,6 @@ import net.vonforst.evmap.ui.updateNightMode
|
||||
import org.acra.config.dialog
|
||||
import org.acra.config.httpSender
|
||||
import org.acra.config.limiter
|
||||
import org.acra.config.mailSender
|
||||
import org.acra.data.StringFormat
|
||||
import org.acra.ktx.initAcra
|
||||
import org.acra.sender.HttpSender
|
||||
@@ -37,21 +36,14 @@ class EvMapApplication : Application(), Configuration.Provider {
|
||||
initAcra {
|
||||
buildConfigClass = BuildConfig::class.java
|
||||
|
||||
if (BuildConfig.FLAVOR_automotive == "automotive") {
|
||||
// Vehicles often don't have an email app, so use HTTP to send instead
|
||||
reportFormat = StringFormat.JSON
|
||||
httpSender {
|
||||
uri = getString(R.string.acra_backend_url)
|
||||
val creds = getString(R.string.acra_credentials).split(":")
|
||||
basicAuthLogin = creds[0]
|
||||
basicAuthPassword = creds[1]
|
||||
httpMethod = HttpSender.Method.POST
|
||||
}
|
||||
} else {
|
||||
reportFormat = StringFormat.KEY_VALUE_LIST
|
||||
mailSender {
|
||||
mailTo = "evmap+crashreport@vonforst.net"
|
||||
}
|
||||
// Vehicles often don't have an email app, so use HTTP to send instead
|
||||
reportFormat = StringFormat.JSON
|
||||
httpSender {
|
||||
uri = getString(R.string.acra_backend_url)
|
||||
val creds = getString(R.string.acra_credentials).split(":")
|
||||
basicAuthLogin = creds[0]
|
||||
basicAuthPassword = creds[1]
|
||||
httpMethod = HttpSender.Method.POST
|
||||
}
|
||||
|
||||
dialog {
|
||||
|
||||
@@ -139,7 +139,7 @@ fun buildDetails(
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatTeslaParkingFee(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Context) =
|
||||
fun formatTeslaParkingFee(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Context) =
|
||||
teslaPricing.memberRates?.activePricebook?.parking?.let { parkingFee ->
|
||||
ctx.getString(
|
||||
R.string.tesla_pricing_blocking_fee,
|
||||
@@ -147,7 +147,7 @@ private fun formatTeslaParkingFee(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Co
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatTeslaPricing(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Context) =
|
||||
fun formatTeslaPricing(teslaPricing: TeslaGraphQlApi.Pricing, ctx: Context) =
|
||||
buildSpannedString {
|
||||
teslaPricing.memberRates?.let { memberRates ->
|
||||
append(
|
||||
|
||||
@@ -170,9 +170,11 @@ class AvailabilityRepository(context: Context) {
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.cookieJar(JavaNetCookieJar(cookieManager))
|
||||
.build()
|
||||
private val teslaAvailabilityDetector =
|
||||
TeslaAvailabilityDetector(okhttp, EncryptedPreferenceDataStore(context))
|
||||
private val availabilityDetectors = listOf(
|
||||
RheinenergieAvailabilityDetector(okhttp),
|
||||
TeslaAvailabilityDetector(okhttp, EncryptedPreferenceDataStore(context)),
|
||||
teslaAvailabilityDetector,
|
||||
EnBwAvailabilityDetector(okhttp),
|
||||
NewMotionAvailabilityDetector(okhttp)
|
||||
)
|
||||
@@ -199,4 +201,10 @@ class AvailabilityRepository(context: Context) {
|
||||
}
|
||||
return value ?: Resource.error(null, null)
|
||||
}
|
||||
|
||||
fun isSupercharger(charger: ChargeLocation) =
|
||||
teslaAvailabilityDetector.isChargerSupported(charger)
|
||||
|
||||
fun isTeslaSupported(charger: ChargeLocation) =
|
||||
teslaAvailabilityDetector.isChargerSupported(charger) && teslaAvailabilityDetector.isSignedIn()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package net.vonforst.evmap.api.availability
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import com.squareup.moshi.FromJson
|
||||
import com.squareup.moshi.Json
|
||||
@@ -102,6 +103,18 @@ interface TeslaAuthenticationApi {
|
||||
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
|
||||
)
|
||||
}
|
||||
|
||||
fun buildSignInUri(codeChallenge: String): Uri =
|
||||
Uri.parse("https://auth.tesla.com/oauth2/v3/authorize").buildUpon()
|
||||
.appendQueryParameter("client_id", "ownerapi")
|
||||
.appendQueryParameter("code_challenge", codeChallenge)
|
||||
.appendQueryParameter("code_challenge_method", "S256")
|
||||
.appendQueryParameter("redirect_uri", "https://auth.tesla.com/void/callback")
|
||||
.appendQueryParameter("response_type", "code")
|
||||
.appendQueryParameter("scope", "openid email offline_access")
|
||||
.appendQueryParameter("state", "123").build()
|
||||
|
||||
val resultUrlPrefix = "https://auth.tesla.com/void/callback"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,4 +655,6 @@ class TeslaAvailabilityDetector(
|
||||
}
|
||||
}
|
||||
|
||||
fun isSignedIn() = tokenStore.teslaRefreshToken != null
|
||||
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package net.vonforst.evmap.api.fronyx
|
||||
|
||||
import android.content.Context
|
||||
import com.squareup.moshi.JsonDataException
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.api.availability.AvailabilityDetectorException
|
||||
import net.vonforst.evmap.api.availability.AvailabilityRepository
|
||||
import net.vonforst.evmap.api.availability.ChargeLocationStatus
|
||||
import net.vonforst.evmap.api.equivalentPlugTypes
|
||||
import net.vonforst.evmap.api.nameForPlugType
|
||||
import net.vonforst.evmap.api.stringProvider
|
||||
import net.vonforst.evmap.model.ChargeLocation
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.viewmodel.Resource
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
data class PredictionData(
|
||||
val predictionGraph: Map<ZonedDateTime, Double>?,
|
||||
val maxValue: Double,
|
||||
val predictedChargepoints: List<Chargepoint>,
|
||||
val isPercentage: Boolean,
|
||||
val description: String?
|
||||
)
|
||||
|
||||
class PredictionRepository(private val context: Context) {
|
||||
private val predictionApi = FronyxApi(context.getString(R.string.fronyx_key))
|
||||
private val prefs = PreferenceDataSource(context)
|
||||
|
||||
suspend fun getPredictionData(
|
||||
charger: ChargeLocation,
|
||||
availability: ChargeLocationStatus?,
|
||||
filteredConnectors: Set<String>? = null
|
||||
): PredictionData {
|
||||
val fronyxPrediction = availability?.evseIds?.let { evseIds ->
|
||||
getFronyxPrediction(charger, evseIds, filteredConnectors)
|
||||
}?.data
|
||||
val graph = buildPredictionGraph(availability, fronyxPrediction)
|
||||
val predictedChargepoints = getPredictedChargepoints(charger, filteredConnectors)
|
||||
val maxValue = getPredictionMaxValue(availability, fronyxPrediction, predictedChargepoints)
|
||||
val isPercentage = predictionIsPercentage(availability, fronyxPrediction)
|
||||
val description = getDescription(charger, predictedChargepoints)
|
||||
return PredictionData(
|
||||
graph, maxValue, predictedChargepoints, isPercentage, description
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getFronyxPrediction(
|
||||
charger: ChargeLocation,
|
||||
evseIds: Map<Chargepoint, List<String>>,
|
||||
filteredConnectors: Set<String>?
|
||||
): Resource<List<FronyxEvseIdResponse>> {
|
||||
if (!prefs.predictionEnabled) return Resource.success(null)
|
||||
|
||||
val allEvseIds =
|
||||
evseIds.filterKeys {
|
||||
FronyxApi.isChargepointSupported(charger, it) &&
|
||||
filteredConnectors?.let { filtered ->
|
||||
equivalentPlugTypes(
|
||||
it.type
|
||||
).any { filtered.contains(it) }
|
||||
} ?: true
|
||||
}.flatMap { it.value }
|
||||
if (allEvseIds.isEmpty()) {
|
||||
return Resource.success(emptyList())
|
||||
}
|
||||
try {
|
||||
val result = predictionApi.getPredictionsForEvseIds(allEvseIds)
|
||||
if (result.size == allEvseIds.size) {
|
||||
return Resource.success(result)
|
||||
} else {
|
||||
return Resource.error("not all EVSEIDs found", null)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
return Resource.error(e.message, null)
|
||||
} catch (e: HttpException) {
|
||||
e.printStackTrace()
|
||||
return Resource.error(e.message, null)
|
||||
} catch (e: AvailabilityDetectorException) {
|
||||
e.printStackTrace()
|
||||
return Resource.error(e.message, null)
|
||||
} catch (e: JsonDataException) {
|
||||
// malformed JSON response from fronyx API
|
||||
e.printStackTrace()
|
||||
return Resource.error(e.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildPredictionGraph(
|
||||
availability: ChargeLocationStatus?,
|
||||
prediction: List<FronyxEvseIdResponse>?
|
||||
): Map<ZonedDateTime, Double>? {
|
||||
val congestionHistogram = availability?.congestionHistogram
|
||||
return if (congestionHistogram != null && prediction == null) {
|
||||
congestionHistogram.mapIndexed { i, value ->
|
||||
LocalTime.of(i, 0).atDate(LocalDate.now())
|
||||
.atZone(ZoneId.systemDefault()) to value
|
||||
}.toMap()
|
||||
} else {
|
||||
prediction?.let { responses ->
|
||||
if (responses.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
val evseIds = responses.map { it.evseId }
|
||||
val groupByTimestamp = responses.flatMap { response ->
|
||||
response.predictions.map {
|
||||
Triple(
|
||||
it.timestamp,
|
||||
response.evseId,
|
||||
it.status
|
||||
)
|
||||
}
|
||||
}
|
||||
.groupBy { it.first } // group by timestamp
|
||||
.mapValues { it.value.map { it.second to it.third } } // only keep EVSEID and status
|
||||
.filterValues { it.map { it.first } == evseIds } // remove values where status is not given for all EVSEs
|
||||
.filterKeys { it > ZonedDateTime.now() } // only show predictions in the future
|
||||
|
||||
groupByTimestamp.mapValues {
|
||||
it.value.count {
|
||||
it.second == FronyxStatus.UNAVAILABLE
|
||||
}.toDouble()
|
||||
}.ifEmpty { null }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPredictedChargepoints(
|
||||
charger: ChargeLocation,
|
||||
filteredConnectors: Set<String>?
|
||||
) =
|
||||
charger.chargepoints.filter {
|
||||
FronyxApi.isChargepointSupported(charger, it) &&
|
||||
filteredConnectors?.let { filtered ->
|
||||
equivalentPlugTypes(it.type).any {
|
||||
filtered.contains(
|
||||
it
|
||||
)
|
||||
}
|
||||
} ?: true
|
||||
}
|
||||
|
||||
private fun getPredictionMaxValue(
|
||||
availability: ChargeLocationStatus?,
|
||||
prediction: List<FronyxEvseIdResponse>?,
|
||||
predictedChargepoints: List<Chargepoint>
|
||||
): Double = if (availability?.congestionHistogram != null && prediction == null) {
|
||||
1.0
|
||||
} else {
|
||||
predictedChargepoints.sumOf { it.count }.toDouble()
|
||||
}
|
||||
|
||||
private fun predictionIsPercentage(
|
||||
availability: ChargeLocationStatus?,
|
||||
prediction: List<FronyxEvseIdResponse>?
|
||||
) =
|
||||
availability?.congestionHistogram != null && prediction == null
|
||||
|
||||
|
||||
private fun getDescription(
|
||||
charger: ChargeLocation,
|
||||
predictedChargepoints: List<Chargepoint>
|
||||
): String? {
|
||||
val allChargepoints = charger.chargepoints
|
||||
|
||||
val predictedChargepointTypes = predictedChargepoints.map { it.type }.distinct()
|
||||
return if (allChargepoints == predictedChargepoints) {
|
||||
null
|
||||
} else if (predictedChargepointTypes.size == 1) {
|
||||
context.getString(
|
||||
R.string.prediction_only,
|
||||
nameForPlugType(context.stringProvider(), predictedChargepointTypes[0])
|
||||
)
|
||||
} else {
|
||||
context.getString(
|
||||
R.string.prediction_only,
|
||||
context.getString(R.string.prediction_dc_plugs_only)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,13 +45,15 @@ interface LocationAwareScreen {
|
||||
class CarAppService : androidx.car.app.CarAppService() {
|
||||
private val CHANNEL_ID = "car_location"
|
||||
private val NOTIFICATION_ID = 1000
|
||||
private var foregroundStarted = false
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
fun ensureForegroundService() {
|
||||
// we want to run as a foreground service to make sure we can use location
|
||||
createNotificationChannel()
|
||||
startForeground(NOTIFICATION_ID, getNotification())
|
||||
if (!foregroundStarted) {
|
||||
createNotificationChannel()
|
||||
startForeground(NOTIFICATION_ID, getNotification())
|
||||
foregroundStarted = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
@@ -222,6 +224,7 @@ class EVMapSession(val cas: CarAppService) : Session(), DefaultLifecycleObserver
|
||||
@SuppressLint("MissingPermission")
|
||||
fun requestLocationUpdates() {
|
||||
if (!locationPermissionGranted()) return
|
||||
cas.ensureForegroundService()
|
||||
Log.i(TAG, "Requesting location updates")
|
||||
requestCarHardwareLocationUpdates()
|
||||
requestPhoneLocationUpdates()
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.car.app.CarToast
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.constraints.ConstraintManager
|
||||
import androidx.car.app.model.*
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.core.graphics.scale
|
||||
import androidx.core.text.HtmlCompat
|
||||
@@ -23,10 +24,16 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.vonforst.evmap.*
|
||||
import net.vonforst.evmap.adapter.formatTeslaParkingFee
|
||||
import net.vonforst.evmap.adapter.formatTeslaPricing
|
||||
import net.vonforst.evmap.api.availability.AvailabilityRepository
|
||||
import net.vonforst.evmap.api.availability.ChargeLocationStatus
|
||||
import net.vonforst.evmap.api.availability.TeslaGraphQlApi
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
|
||||
import net.vonforst.evmap.api.createApi
|
||||
import net.vonforst.evmap.api.fronyx.FronyxApi
|
||||
import net.vonforst.evmap.api.fronyx.PredictionData
|
||||
import net.vonforst.evmap.api.fronyx.PredictionRepository
|
||||
import net.vonforst.evmap.api.iconForPlugType
|
||||
import net.vonforst.evmap.api.nameForPlugType
|
||||
import net.vonforst.evmap.api.stringProvider
|
||||
@@ -45,6 +52,8 @@ import net.vonforst.evmap.viewmodel.awaitFinished
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
||||
@@ -52,12 +61,17 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
var charger: ChargeLocation? = null
|
||||
var photo: Bitmap? = null
|
||||
private var availability: ChargeLocationStatus? = null
|
||||
private var prediction: PredictionData? = null
|
||||
private var fronyxSupported = false
|
||||
private var teslaSupported = false
|
||||
|
||||
val prefs = PreferenceDataSource(ctx)
|
||||
private val db = AppDatabase.getInstance(carContext)
|
||||
private val repo =
|
||||
ChargeLocationsRepository(createApi(prefs.dataSource, ctx), lifecycleScope, db, prefs)
|
||||
private val availabilityRepo = AvailabilityRepository(ctx)
|
||||
private val predictionRepo = PredictionRepository(ctx)
|
||||
private val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||
|
||||
private val imageSize = 128 // images should be 128dp according to docs
|
||||
private val imageSizeLarge = 480 // images should be 480 x 480 dp according to docs
|
||||
@@ -302,9 +316,94 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
addText(charger.amenities)
|
||||
}.build())
|
||||
}
|
||||
if (rows.count() < maxRows && ((fronyxSupported && prefs.predictionEnabled) || teslaSupported)) {
|
||||
rows.add(1, Row.Builder().apply {
|
||||
setTitle(
|
||||
if (fronyxSupported) {
|
||||
carContext.getString(R.string.utilization_prediction) + " (" + carContext.getString(
|
||||
R.string.powered_by_fronyx
|
||||
) + ")"
|
||||
} else carContext.getString(R.string.average_utilization)
|
||||
)
|
||||
generatePredictionGraph()?.let { addText(it) }
|
||||
?: addText(carContext.getString(if (prediction != null) R.string.auto_no_data else R.string.loading))
|
||||
}.build())
|
||||
}
|
||||
if (rows.count() < maxRows && teslaSupported) {
|
||||
val teslaPricing = availability?.extraData as? TeslaGraphQlApi.Pricing
|
||||
rows.add(3, Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.cost))
|
||||
teslaPricing?.let {
|
||||
var text = formatTeslaPricing(teslaPricing, carContext) as CharSequence
|
||||
formatTeslaParkingFee(teslaPricing, carContext)?.let { text += "\n\n" + it }
|
||||
addText(text)
|
||||
} ?: {
|
||||
addText(carContext.getString(if (prediction != null) R.string.auto_no_data else R.string.loading))
|
||||
}
|
||||
}.build())
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
private fun generatePredictionGraph(): CharSequence? {
|
||||
val predictionData = prediction ?: return null
|
||||
val graphData = predictionData.predictionGraph?.toList() ?: return null
|
||||
val maxValue = predictionData.maxValue
|
||||
|
||||
val maxWidth = if (BuildConfig.FLAVOR_automotive == "automotive") 25 else 18
|
||||
val step = maxOf(graphData.size.toFloat() / maxWidth, 1f)
|
||||
val values = graphData.map { it.second }
|
||||
|
||||
val graph = buildGraph(values, step, maxValue, predictionData.isPercentage)
|
||||
|
||||
val measurer = TextMeasurer(carContext)
|
||||
val width = measurer.measureText(graph)
|
||||
|
||||
val startTime = timeFormat.format(graphData[0].first)
|
||||
val endTime = timeFormat.format(graphData.last().first)
|
||||
|
||||
val baseWidth = measurer.measureText(startTime + endTime)
|
||||
val spaceWidth = measurer.measureText(" ")
|
||||
val numSpaces = floor((width - baseWidth) / spaceWidth).toInt()
|
||||
val legend = startTime + " ".repeat(numSpaces) + endTime
|
||||
|
||||
return graph + "\n" + legend
|
||||
}
|
||||
|
||||
private fun buildGraph(
|
||||
values: List<Double>,
|
||||
step: Float,
|
||||
maxValue: Double,
|
||||
isPercentage: Boolean
|
||||
): CharSequence {
|
||||
val sparklines = "▁▂▃▄▅▆▇█"
|
||||
val graph = SpannableStringBuilder()
|
||||
var i = 0f
|
||||
while (i.roundToInt() < values.size) {
|
||||
val v = values[i.roundToInt()]
|
||||
val fraction = v / maxValue
|
||||
val sparkline = sparklines[(fraction * (sparklines.length - 1)).roundToInt()].toString()
|
||||
|
||||
val color = if (isPercentage) {
|
||||
when (v) {
|
||||
in 0.0..0.5 -> CarColor.GREEN
|
||||
in 0.5..0.8 -> CarColor.YELLOW
|
||||
else -> CarColor.RED
|
||||
}
|
||||
} else {
|
||||
if (v < maxValue) CarColor.GREEN else CarColor.RED
|
||||
}
|
||||
|
||||
graph.append(
|
||||
sparkline,
|
||||
ForegroundCarColorSpan.create(color),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
i += step
|
||||
}
|
||||
return graph
|
||||
}
|
||||
|
||||
private fun generateCostStatusText(cost: Cost): CharSequence {
|
||||
val string = SpannableString(cost.getStatusText(carContext, emoji = true))
|
||||
// replace emoji with CarIcon
|
||||
@@ -383,8 +482,10 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
} else {
|
||||
append(nameForPlugType(carContext.stringProvider(), cp.type))
|
||||
}
|
||||
append(" ")
|
||||
append(cp.formatPower())
|
||||
cp.formatPower()?.let {
|
||||
append(" ")
|
||||
append(it)
|
||||
}
|
||||
}
|
||||
availability?.status?.get(cp)?.let { status ->
|
||||
chargepointsText.append(
|
||||
@@ -475,12 +576,23 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
|
||||
)
|
||||
this@ChargerDetailScreen.photo = outImg
|
||||
}
|
||||
fronyxSupported = charger.chargepoints.any {
|
||||
FronyxApi.isChargepointSupported(
|
||||
charger,
|
||||
it
|
||||
)
|
||||
} && !availabilityRepo.isSupercharger(charger)
|
||||
teslaSupported = availabilityRepo.isTeslaSupported(charger)
|
||||
|
||||
invalidate()
|
||||
|
||||
availability = availabilityRepo.getAvailability(charger).data
|
||||
|
||||
invalidate()
|
||||
|
||||
prediction = predictionRepo.getPredictionData(charger, availability)
|
||||
|
||||
invalidate()
|
||||
} else {
|
||||
withContext(Dispatchers.Main) {
|
||||
CarToast.makeText(carContext, R.string.connection_error, CarToast.LENGTH_LONG)
|
||||
|
||||
@@ -41,7 +41,9 @@ class FilterScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
|
||||
if (filterStatus in listOf(FILTERS_DISABLED, FILTERS_FAVORITES, FILTERS_CUSTOM)) {
|
||||
page = 0
|
||||
} else {
|
||||
page = paginateProfiles(it).indexOfFirst { it.any { it.id == filterStatus } }
|
||||
val index =
|
||||
paginateProfiles(it).indexOfFirst { it.any { it.id == filterStatus } }
|
||||
page = index.takeUnless { it == -1 } ?: 0
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.add
|
||||
import androidx.fragment.app.commit
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragment
|
||||
|
||||
class OAuthLoginActivity : AppCompatActivity(R.layout.activity_oauth_login) {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (savedInstanceState == null) {
|
||||
supportFragmentManager.commit {
|
||||
setReorderingAllowed(true)
|
||||
add<OAuthLoginFragment>(R.id.fragment_container_view, args = intent.extras)
|
||||
}
|
||||
}
|
||||
|
||||
LocalBroadcastManager.getInstance(this).registerReceiver(object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context, intent: Intent) {
|
||||
finish()
|
||||
}
|
||||
}, IntentFilter(OAuthLoginFragment.ACTION_OAUTH_RESULT))
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,11 @@ import androidx.car.app.constraints.ConstraintManager
|
||||
import androidx.car.app.model.*
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.vonforst.evmap.R
|
||||
import okio.IOException
|
||||
|
||||
abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
|
||||
SearchTemplate.SearchCallback {
|
||||
@@ -22,9 +25,20 @@ abstract class MultiSelectSearchScreen<T>(ctx: CarContext) : Screen(ctx),
|
||||
override fun onGetTemplate(): Template {
|
||||
if (fullList == null) {
|
||||
lifecycleScope.launch {
|
||||
fullList = loadData()
|
||||
filterList()
|
||||
invalidate()
|
||||
try {
|
||||
fullList = loadData()
|
||||
filterList()
|
||||
invalidate()
|
||||
} catch (e: IOException) {
|
||||
withContext(Dispatchers.Main) {
|
||||
CarToast.makeText(
|
||||
carContext,
|
||||
R.string.generic_connection_error,
|
||||
CarToast.LENGTH_LONG
|
||||
).show()
|
||||
screenManager.pop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package net.vonforst.evmap.auto
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager.NameNotFoundException
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorManager
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.IInterface
|
||||
import android.text.Html
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.car.app.CarContext
|
||||
@@ -13,16 +18,27 @@ 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.content.IntentCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.launch
|
||||
import net.vonforst.evmap.*
|
||||
import net.vonforst.evmap.api.availability.TeslaAuthenticationApi
|
||||
import net.vonforst.evmap.api.availability.TeslaOwnerApi
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceApi
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceCar
|
||||
import net.vonforst.evmap.api.chargeprice.ChargepriceTariff
|
||||
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragment
|
||||
import net.vonforst.evmap.fragment.oauth.OAuthLoginFragmentArgs
|
||||
import net.vonforst.evmap.storage.AppDatabase
|
||||
import net.vonforst.evmap.storage.EncryptedPreferenceDataStore
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.IOException
|
||||
import java.time.Instant
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
@@ -125,6 +141,7 @@ class SettingsScreen(ctx: CarContext, val session: EVMapSession) : Screen(ctx) {
|
||||
|
||||
class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
|
||||
val prefs = PreferenceDataSource(ctx)
|
||||
val encryptedPrefs = EncryptedPreferenceDataStore(ctx)
|
||||
val db = AppDatabase.getInstance(ctx)
|
||||
|
||||
val dataSourceNames = carContext.resources.getStringArray(R.array.pref_data_source_names)
|
||||
@@ -134,6 +151,8 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
|
||||
val searchProviderValues =
|
||||
carContext.resources.getStringArray(R.array.pref_search_provider_values)
|
||||
|
||||
var teslaLoggingIn = false
|
||||
|
||||
override fun onGetTemplate(): Template {
|
||||
return ListTemplate.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.settings_data_sources))
|
||||
@@ -183,9 +202,122 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
|
||||
}
|
||||
}
|
||||
}.build())
|
||||
addItem(
|
||||
Row.Builder()
|
||||
.setTitle(carContext.getString(R.string.pref_prediction_enabled))
|
||||
.addText(carContext.getString(R.string.pref_prediction_enabled_summary))
|
||||
.setToggle(Toggle.Builder {
|
||||
prefs.predictionEnabled = it
|
||||
}.setChecked(prefs.predictionEnabled).build())
|
||||
.build()
|
||||
)
|
||||
addItem(Row.Builder().apply {
|
||||
setTitle(carContext.getString(R.string.pref_tesla_account))
|
||||
addText(
|
||||
if (encryptedPrefs.teslaRefreshToken != null) {
|
||||
carContext.getString(
|
||||
R.string.pref_tesla_account_enabled,
|
||||
encryptedPrefs.teslaEmail
|
||||
)
|
||||
} else if (teslaLoggingIn) {
|
||||
carContext.getString(R.string.logging_in)
|
||||
} else {
|
||||
carContext.getString(R.string.pref_tesla_account_disabled)
|
||||
}
|
||||
)
|
||||
if (encryptedPrefs.teslaRefreshToken != null) {
|
||||
setOnClickListener {
|
||||
teslaLogout()
|
||||
}
|
||||
} else {
|
||||
setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
teslaLogin()
|
||||
})
|
||||
}
|
||||
}.build())
|
||||
}.build())
|
||||
}.build()
|
||||
}
|
||||
|
||||
private fun teslaLogin() {
|
||||
val codeVerifier = TeslaAuthenticationApi.generateCodeVerifier()
|
||||
val codeChallenge = TeslaAuthenticationApi.generateCodeChallenge(codeVerifier)
|
||||
val uri = TeslaAuthenticationApi.buildSignInUri(codeChallenge)
|
||||
|
||||
val args = OAuthLoginFragmentArgs(
|
||||
uri.toString(),
|
||||
TeslaAuthenticationApi.resultUrlPrefix,
|
||||
"#000000"
|
||||
).toBundle()
|
||||
val intent = Intent(carContext, OAuthLoginActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.putExtras(args)
|
||||
|
||||
LocalBroadcastManager.getInstance(carContext)
|
||||
.registerReceiver(object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context, intent: Intent) {
|
||||
val url = IntentCompat.getParcelableExtra(
|
||||
intent,
|
||||
OAuthLoginFragment.EXTRA_URL,
|
||||
Uri::class.java
|
||||
)
|
||||
teslaGetAccessToken(url!!, codeVerifier)
|
||||
}
|
||||
}, IntentFilter(OAuthLoginFragment.ACTION_OAUTH_RESULT))
|
||||
|
||||
carContext.startActivity(intent)
|
||||
|
||||
if (BuildConfig.FLAVOR_automotive != "automotive") {
|
||||
CarToast.makeText(
|
||||
carContext,
|
||||
R.string.opened_on_phone,
|
||||
CarToast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun teslaGetAccessToken(url: Uri, codeVerifier: String) {
|
||||
teslaLoggingIn = true
|
||||
invalidate()
|
||||
|
||||
val code = url.getQueryParameter("code") ?: return
|
||||
val okhttp = OkHttpClient.Builder().addDebugInterceptors().build()
|
||||
val request = TeslaAuthenticationApi.AuthCodeRequest(code, codeVerifier)
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val time = Instant.now().epochSecond
|
||||
val response =
|
||||
TeslaAuthenticationApi.create(okhttp).getToken(request)
|
||||
val userResponse =
|
||||
TeslaOwnerApi.create(okhttp, response.accessToken).getUserInfo()
|
||||
|
||||
encryptedPrefs.teslaEmail = userResponse.response.email
|
||||
encryptedPrefs.teslaAccessToken = response.accessToken
|
||||
encryptedPrefs.teslaAccessTokenExpiry = time + response.expiresIn
|
||||
encryptedPrefs.teslaRefreshToken = response.refreshToken
|
||||
} catch (e: IOException) {
|
||||
CarToast.makeText(
|
||||
carContext,
|
||||
R.string.generic_connection_error,
|
||||
CarToast.LENGTH_SHORT
|
||||
).show()
|
||||
} finally {
|
||||
teslaLoggingIn = false
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
private fun teslaLogout() {
|
||||
// sign out
|
||||
encryptedPrefs.teslaRefreshToken = null
|
||||
encryptedPrefs.teslaAccessToken = null
|
||||
encryptedPrefs.teslaAccessTokenExpiry = -1
|
||||
encryptedPrefs.teslaEmail = null
|
||||
CarToast.makeText(carContext, R.string.logged_out, CarToast.LENGTH_SHORT).show()
|
||||
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
class ChooseDataSourceScreen(
|
||||
|
||||
@@ -4,7 +4,9 @@ import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Typeface
|
||||
import android.net.Uri
|
||||
import android.text.TextPaint
|
||||
import androidx.browser.customtabs.CustomTabColorSchemeParams
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.car.app.CarContext
|
||||
@@ -258,4 +260,17 @@ class DummyReturnScreen(ctx: CarContext) : Screen(ctx) {
|
||||
.build()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class TextMeasurer(ctx: CarContext) {
|
||||
val textPaint = TextPaint()
|
||||
|
||||
init {
|
||||
textPaint.textSize = ctx.resources.displayMetrics.density * 24
|
||||
textPaint.typeface = Typeface.DEFAULT
|
||||
}
|
||||
|
||||
fun measureText(text: CharSequence): Float {
|
||||
return textPaint.measureText(text, 0, text.length)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import net.vonforst.evmap.api.equivalentPlugTypes
|
||||
import net.vonforst.evmap.databinding.FragmentChargepriceBinding
|
||||
import net.vonforst.evmap.databinding.FragmentChargepriceHeaderBinding
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import net.vonforst.evmap.navigation.safeNavigate
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.viewmodel.ChargepriceViewModel
|
||||
import net.vonforst.evmap.viewmodel.Status
|
||||
@@ -81,7 +82,7 @@ class ChargepriceFragment : Fragment() {
|
||||
}
|
||||
.setPositiveButton(R.string.donate) { di, _ ->
|
||||
di.dismiss()
|
||||
findNavController().navigate(R.id.action_chargeprice_to_donateFragment)
|
||||
findNavController().safeNavigate(ChargepriceFragmentDirections.actionChargepriceToDonateFragment())
|
||||
}
|
||||
.show()
|
||||
}
|
||||
@@ -197,7 +198,7 @@ class ChargepriceFragment : Fragment() {
|
||||
}
|
||||
|
||||
binding.btnSettings.setOnClickListener {
|
||||
findNavController().navigate(R.id.action_chargeprice_to_chargepriceSettingsFragment)
|
||||
findNavController().safeNavigate(ChargepriceFragmentDirections.actionChargepriceToChargepriceSettingsFragment())
|
||||
}
|
||||
|
||||
headerBinding.batteryRange.setLabelFormatter { value: Float ->
|
||||
|
||||
@@ -77,6 +77,7 @@ import net.vonforst.evmap.location.FusionEngine
|
||||
import net.vonforst.evmap.location.LocationEngine
|
||||
import net.vonforst.evmap.location.Priority
|
||||
import net.vonforst.evmap.model.*
|
||||
import net.vonforst.evmap.navigation.safeNavigate
|
||||
import net.vonforst.evmap.shouldUseImperialUnits
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.ui.*
|
||||
@@ -277,7 +278,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
|
||||
if (prefs.appStartCounter > 5 && !prefs.opensourceDonationsDialogShown) {
|
||||
try {
|
||||
findNavController().navigate(R.id.action_map_to_opensource_donations)
|
||||
findNavController().safeNavigate(MapFragmentDirections.actionMapToOpensourceDonations())
|
||||
} catch (ignored: IllegalArgumentException) {
|
||||
// when there is already another navigation going on
|
||||
} catch (ignored: IllegalStateException) {
|
||||
@@ -286,7 +287,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
}
|
||||
/*if (!prefs.update060AndroidAutoDialogShown) {
|
||||
try {
|
||||
navController.navigate(R.id.action_map_to_update_060_androidauto)
|
||||
navController.safeNavigate(MapFragmentDirections.actionMapToUpdate060AndroidAuto())
|
||||
} catch (ignored: IllegalArgumentException) {
|
||||
// when there is already another navigation going on
|
||||
}
|
||||
@@ -375,10 +376,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
val charger = vm.charger.value?.data ?: return@setOnClickListener
|
||||
val extras =
|
||||
FragmentNavigatorExtras(binding.detailView.btnChargeprice to getString(R.string.shared_element_chargeprice))
|
||||
findNavController().navigate(
|
||||
R.id.action_map_to_chargepriceFragment,
|
||||
ChargepriceFragmentArgs(charger).toBundle(),
|
||||
null, extras
|
||||
findNavController().safeNavigate(
|
||||
MapFragmentDirections.actionMapToChargepriceFragment(charger),
|
||||
extras
|
||||
)
|
||||
}
|
||||
binding.detailView.btnChargerWebsite.setOnClickListener {
|
||||
@@ -386,9 +386,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
charger.chargerUrl?.let { (activity as? MapsActivity)?.openUrl(it) }
|
||||
}
|
||||
binding.detailView.btnLogin.setOnClickListener {
|
||||
findNavController().navigate(
|
||||
R.id.settings_data,
|
||||
DataSettingsFragmentArgs(true).toBundle()
|
||||
findNavController().safeNavigate(
|
||||
MapFragmentDirections.actionMapToDataSettings(true)
|
||||
)
|
||||
}
|
||||
binding.detailView.imgPredictionSource.setOnClickListener {
|
||||
@@ -1043,7 +1042,7 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
binding.search.requestFocus()
|
||||
binding.search.setSelection(locationName.length)
|
||||
}
|
||||
if (context.checkAnyLocationPermission()) {
|
||||
if (context.checkAnyLocationPermission() && prefs.currentMapMyLocationEnabled) {
|
||||
enableLocation(!positionSet, false)
|
||||
positionSet = true
|
||||
}
|
||||
@@ -1231,8 +1230,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
|
||||
lifecycleScope.launch {
|
||||
vm.copyFiltersToCustom()
|
||||
requireView().findNavController().navigate(
|
||||
R.id.action_map_to_filterFragment
|
||||
requireView().findNavController().safeNavigate(
|
||||
MapFragmentDirections.actionMapToFilterFragment()
|
||||
)
|
||||
}
|
||||
true
|
||||
@@ -1240,8 +1239,8 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
R.id.menu_manage_filter_profiles -> {
|
||||
exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
|
||||
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
|
||||
requireView().findNavController().navigate(
|
||||
R.id.action_map_to_filterProfilesFragment
|
||||
requireView().findNavController().safeNavigate(
|
||||
MapFragmentDirections.actionMapToFilterProfilesFragment()
|
||||
)
|
||||
true
|
||||
}
|
||||
@@ -1400,6 +1399,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
|
||||
prefs.currentMapLocation = it.bounds.center
|
||||
prefs.currentMapZoom = it.zoom
|
||||
}
|
||||
vm.myLocationEnabled.value?.let {
|
||||
prefs.currentMapMyLocationEnabled = it
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.viewpager2.widget.ViewPager2
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.databinding.*
|
||||
import net.vonforst.evmap.model.FILTERS_DISABLED
|
||||
import net.vonforst.evmap.navigation.safeNavigate
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
|
||||
class OnboardingFragment : Fragment() {
|
||||
@@ -82,7 +83,7 @@ class OnboardingFragment : Fragment() {
|
||||
|
||||
fun goToNext() {
|
||||
if (binding.viewPager.currentItem == adapter.itemCount - 1) {
|
||||
findNavController().navigate(R.id.action_onboarding_to_map)
|
||||
findNavController().safeNavigate(OnboardingFragmentDirections.actionOnboardingToMap())
|
||||
} else {
|
||||
binding.viewPager.setCurrentItem(binding.viewPager.currentItem + 1, true)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package net.vonforst.evmap.fragment.oauth
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
@@ -13,17 +14,25 @@ import android.webkit.CookieManager
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
import net.vonforst.evmap.MapsActivity
|
||||
import net.vonforst.evmap.R
|
||||
import java.lang.IllegalStateException
|
||||
|
||||
class OAuthLoginFragment : Fragment() {
|
||||
companion object {
|
||||
val ACTION_OAUTH_RESULT = "oauth_result"
|
||||
val EXTRA_URL = "url"
|
||||
}
|
||||
|
||||
private lateinit var webView: WebView
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -43,10 +52,24 @@ class OAuthLoginFragment : Fragment() {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
|
||||
toolbar.setupWithNavController(
|
||||
findNavController(),
|
||||
(requireActivity() as MapsActivity).appBarConfiguration
|
||||
)
|
||||
val navController = try {
|
||||
findNavController()
|
||||
} catch (e: IllegalStateException) {
|
||||
null
|
||||
// standalone in OAuthLoginActivity
|
||||
}
|
||||
|
||||
if (navController != null) {
|
||||
toolbar.setupWithNavController(
|
||||
navController,
|
||||
(requireActivity() as MapsActivity).appBarConfiguration
|
||||
)
|
||||
} else {
|
||||
toolbar.title = getString(R.string.login)
|
||||
toolbar.navigationIcon =
|
||||
AppCompatResources.getDrawable(requireContext(), R.drawable.ic_arrow_back)
|
||||
toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() }
|
||||
}
|
||||
|
||||
val args = OAuthLoginFragmentArgs.fromBundle(requireArguments())
|
||||
val uri = Uri.parse(args.url)
|
||||
@@ -68,7 +91,12 @@ class OAuthLoginFragment : Fragment() {
|
||||
val result = Bundle()
|
||||
result.putString("url", url.toString())
|
||||
setFragmentResult(args.url, result)
|
||||
findNavController().popBackStack()
|
||||
context?.let {
|
||||
LocalBroadcastManager.getInstance(it).sendBroadcast(
|
||||
Intent(ACTION_OAUTH_RESULT).putExtra(EXTRA_URL, url)
|
||||
)
|
||||
}
|
||||
navController?.popBackStack()
|
||||
}
|
||||
|
||||
return url.host != uri.host
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.mikepenz.aboutlibraries.LibsBuilder
|
||||
import net.vonforst.evmap.BuildConfig
|
||||
import net.vonforst.evmap.MapsActivity
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.navigation.safeNavigate
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
|
||||
|
||||
@@ -108,11 +109,11 @@ class AboutFragment : PreferenceFragmentCompat() {
|
||||
"donate" -> {
|
||||
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||
findNavController().navigate(R.id.action_about_to_donateFragment)
|
||||
findNavController().safeNavigate(AboutFragmentDirections.actionAboutToDonateFragment())
|
||||
true
|
||||
}
|
||||
"github_sponsors" -> {
|
||||
findNavController().navigate(R.id.action_about_to_github_sponsors)
|
||||
findNavController().safeNavigate(AboutFragmentDirections.actionAboutToGithubSponsors())
|
||||
true
|
||||
}
|
||||
"twitter" -> {
|
||||
|
||||
@@ -141,18 +141,11 @@ class DataSettingsFragment : BaseSettingsFragment() {
|
||||
private fun teslaLogin() {
|
||||
val codeVerifier = TeslaAuthenticationApi.generateCodeVerifier()
|
||||
val codeChallenge = TeslaAuthenticationApi.generateCodeChallenge(codeVerifier)
|
||||
val uri = Uri.parse("https://auth.tesla.com/oauth2/v3/authorize").buildUpon()
|
||||
.appendQueryParameter("client_id", "ownerapi")
|
||||
.appendQueryParameter("code_challenge", codeChallenge)
|
||||
.appendQueryParameter("code_challenge_method", "S256")
|
||||
.appendQueryParameter("redirect_uri", "https://auth.tesla.com/void/callback")
|
||||
.appendQueryParameter("response_type", "code")
|
||||
.appendQueryParameter("scope", "openid email offline_access")
|
||||
.appendQueryParameter("state", "123").build()
|
||||
val uri = TeslaAuthenticationApi.buildSignInUri(codeChallenge)
|
||||
|
||||
val args = OAuthLoginFragmentArgs(
|
||||
uri.toString(),
|
||||
"https://auth.tesla.com/void/callback",
|
||||
TeslaAuthenticationApi.resultUrlPrefix,
|
||||
"#000000"
|
||||
).toBundle()
|
||||
|
||||
@@ -184,7 +177,8 @@ class DataSettingsFragment : BaseSettingsFragment() {
|
||||
encryptedPrefs.teslaRefreshToken = response.refreshToken
|
||||
} catch (e: IOException) {
|
||||
view?.let {
|
||||
Snackbar.make(it, R.string.connection_error, Snackbar.LENGTH_SHORT).show()
|
||||
Snackbar.make(it, R.string.generic_connection_error, Snackbar.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
refreshTeslaAccountStatus()
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.view.ViewGroup
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import net.vonforst.evmap.R
|
||||
import net.vonforst.evmap.databinding.DialogOpensourceDonationsBinding
|
||||
import net.vonforst.evmap.navigation.safeNavigate
|
||||
import net.vonforst.evmap.storage.PreferenceDataSource
|
||||
import net.vonforst.evmap.ui.MaterialDialogFragment
|
||||
|
||||
@@ -30,11 +31,11 @@ class OpensourceDonationsDialogFragment : MaterialDialogFragment() {
|
||||
}
|
||||
binding.btnDonate.setOnClickListener {
|
||||
prefs.opensourceDonationsDialogShown = true
|
||||
findNavController().navigate(R.id.action_opensource_donations_to_donate)
|
||||
findNavController().safeNavigate(OpensourceDonationsDialogFragmentDirections.actionOpensourceDonationsToDonate())
|
||||
}
|
||||
binding.btnGithubSponsors.setOnClickListener {
|
||||
prefs.opensourceDonationsDialogShown = true
|
||||
findNavController().navigate(R.id.action_opensource_donations_to_github_sponsors)
|
||||
findNavController().safeNavigate(OpensourceDonationsDialogFragmentDirections.actionOpensourceDonationsToGithubSponsors())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package net.vonforst.evmap.navigation
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDirections
|
||||
import androidx.navigation.Navigator
|
||||
|
||||
fun NavController.safeNavigate(
|
||||
direction: NavDirections,
|
||||
navigatorExtras: Navigator.Extras? = null
|
||||
) {
|
||||
currentDestination?.getAction(direction.actionId) ?: return
|
||||
if (navigatorExtras != null) {
|
||||
navigate(direction, navigatorExtras)
|
||||
} else {
|
||||
navigate(direction)
|
||||
}
|
||||
}
|
||||
@@ -261,8 +261,11 @@ class PreferenceDataSource(val context: Context) {
|
||||
sp.edit().putBoolean("show_chargers_ahead_android_auto", value).apply()
|
||||
}
|
||||
|
||||
val predictionEnabled: Boolean
|
||||
var predictionEnabled: Boolean
|
||||
get() = sp.getBoolean("prediction_enabled", true)
|
||||
set(value) {
|
||||
sp.edit().putBoolean("prediction_enabled", value).apply()
|
||||
}
|
||||
|
||||
var developerModeEnabled: Boolean
|
||||
get() = sp.getBoolean("dev_mode_enabled", false)
|
||||
@@ -291,6 +294,12 @@ class PreferenceDataSource(val context: Context) {
|
||||
sp.edit().putFloat("current_map_zoom", value).apply()
|
||||
}
|
||||
|
||||
var currentMapMyLocationEnabled: Boolean
|
||||
get() = sp.getBoolean("current_map_my_location_enabled", false)
|
||||
set(value) {
|
||||
sp.edit().putBoolean("current_map_my_location_enabled", value).apply()
|
||||
}
|
||||
|
||||
var privacyAccepted: Boolean
|
||||
get() = sp.getBoolean("privacy_accepted", false)
|
||||
set(value) {
|
||||
|
||||
@@ -24,6 +24,8 @@ import net.vonforst.evmap.api.equivalentPlugTypes
|
||||
import net.vonforst.evmap.api.fronyx.FronyxApi
|
||||
import net.vonforst.evmap.api.fronyx.FronyxEvseIdResponse
|
||||
import net.vonforst.evmap.api.fronyx.FronyxStatus
|
||||
import net.vonforst.evmap.api.fronyx.PredictionData
|
||||
import net.vonforst.evmap.api.fronyx.PredictionRepository
|
||||
import net.vonforst.evmap.api.goingelectric.GEChargepoint
|
||||
import net.vonforst.evmap.api.nameForPlugType
|
||||
import net.vonforst.evmap.api.openchargemap.OCMConnection
|
||||
@@ -250,155 +252,12 @@ class MapViewModel(application: Application, private val state: SavedStateHandle
|
||||
it.data?.extraData as? TeslaGraphQlApi.Pricing
|
||||
}
|
||||
|
||||
val predictionApi = FronyxApi(application.getString(R.string.fronyx_key))
|
||||
private val predictionRepository = PredictionRepository(application)
|
||||
|
||||
val prediction: LiveData<Resource<List<FronyxEvseIdResponse>>> by lazy {
|
||||
availability.switchMap { av ->
|
||||
if (!prefs.predictionEnabled) return@switchMap null
|
||||
|
||||
av.data?.evseIds?.let { evseIds ->
|
||||
liveData {
|
||||
emit(Resource.loading(null))
|
||||
|
||||
val charger = charger.value?.data ?: return@liveData
|
||||
val allEvseIds =
|
||||
evseIds.filterKeys {
|
||||
FronyxApi.isChargepointSupported(charger, it) &&
|
||||
filteredConnectors.value?.let { filtered ->
|
||||
equivalentPlugTypes(
|
||||
it.type
|
||||
).any { filtered.contains(it) }
|
||||
} ?: true
|
||||
}.flatMap { it.value }
|
||||
if (allEvseIds.isEmpty()) {
|
||||
emit(Resource.success(emptyList()))
|
||||
return@liveData
|
||||
}
|
||||
try {
|
||||
val result = predictionApi.getPredictionsForEvseIds(allEvseIds)
|
||||
if (result.size == allEvseIds.size) {
|
||||
emit(Resource.success(result))
|
||||
} else {
|
||||
emit(Resource.error("not all EVSEIDs found", null))
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
emit(Resource.error(e.message, null))
|
||||
e.printStackTrace()
|
||||
} catch (e: HttpException) {
|
||||
emit(Resource.error(e.message, null))
|
||||
e.printStackTrace()
|
||||
} 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)) }
|
||||
}
|
||||
}
|
||||
|
||||
val predictionGraph: LiveData<Map<ZonedDateTime, Double>?> =
|
||||
MediatorLiveData<Map<ZonedDateTime, Double>?>().apply {
|
||||
listOf(prediction, availability).forEach {
|
||||
addSource(it) {
|
||||
val congestionHistogram = availability.value?.data?.congestionHistogram
|
||||
val prediction = prediction.value?.data
|
||||
value = if (congestionHistogram != null && prediction == null) {
|
||||
congestionHistogram.mapIndexed { i, value ->
|
||||
LocalTime.of(i, 0).atDate(LocalDate.now())
|
||||
.atZone(ZoneId.systemDefault()) to value
|
||||
}.toMap()
|
||||
} else {
|
||||
prediction?.let { responses ->
|
||||
if (responses.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
val evseIds = responses.map { it.evseId }
|
||||
val groupByTimestamp = responses.flatMap { response ->
|
||||
response.predictions.map {
|
||||
Triple(
|
||||
it.timestamp,
|
||||
response.evseId,
|
||||
it.status
|
||||
)
|
||||
}
|
||||
}
|
||||
.groupBy { it.first } // group by timestamp
|
||||
.mapValues { it.value.map { it.second to it.third } } // only keep EVSEID and status
|
||||
.filterValues { it.map { it.first } == evseIds } // remove values where status is not given for all EVSEs
|
||||
.filterKeys { it > ZonedDateTime.now() } // only show predictions in the future
|
||||
|
||||
groupByTimestamp.mapValues {
|
||||
it.value.count {
|
||||
it.second == FronyxStatus.UNAVAILABLE
|
||||
}.toDouble()
|
||||
}.ifEmpty { null }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val predictedChargepoints = charger.map {
|
||||
it.data?.let { charger ->
|
||||
charger.chargepoints.filter {
|
||||
FronyxApi.isChargepointSupported(charger, it) &&
|
||||
filteredConnectors.value?.let { filtered ->
|
||||
equivalentPlugTypes(it.type).any {
|
||||
filtered.contains(
|
||||
it
|
||||
)
|
||||
}
|
||||
} ?: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val predictionMaxValue: LiveData<Double> = MediatorLiveData<Double>().apply {
|
||||
listOf(prediction, availability).forEach {
|
||||
addSource(it) {
|
||||
value =
|
||||
if (availability.value?.data?.congestionHistogram != null && prediction.value?.data == null) {
|
||||
1.0
|
||||
} else {
|
||||
(predictedChargepoints.value?.sumOf { it.count } ?: 0).toDouble()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val predictionIsPercentage: LiveData<Boolean> = MediatorLiveData<Boolean>().apply {
|
||||
listOf(prediction, availability).forEach {
|
||||
addSource(it) {
|
||||
value =
|
||||
availability.value?.data?.congestionHistogram != null && prediction.value?.data == null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val predictionDescription: LiveData<String?> by lazy {
|
||||
predictedChargepoints.map { predictedChargepoints ->
|
||||
if (predictedChargepoints == null) return@map null
|
||||
val allChargepoints = charger.value?.data?.chargepoints ?: return@map null
|
||||
|
||||
val predictedChargepointTypes = predictedChargepoints.map { it.type }.distinct()
|
||||
if (allChargepoints == predictedChargepoints) {
|
||||
null
|
||||
} else if (predictedChargepointTypes.size == 1) {
|
||||
application.getString(
|
||||
R.string.prediction_only,
|
||||
nameForPlugType(application.stringProvider(), predictedChargepointTypes[0])
|
||||
)
|
||||
} else {
|
||||
application.getString(
|
||||
R.string.prediction_only,
|
||||
application.getString(R.string.prediction_dc_plugs_only)
|
||||
)
|
||||
}
|
||||
val predictionData: LiveData<PredictionData> = availability.switchMap { av ->
|
||||
liveData {
|
||||
val charger = charger.value?.data ?: return@liveData
|
||||
emit(predictionRepository.getPredictionData(charger, av.data, filteredConnectors.value))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
6
app/src/main/res/layout/activity_oauth_login.xml
Normal file
6
app/src/main/res/layout/activity_oauth_login.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/fragment_container_view"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_width="match_parent"
|
||||
android:fitsSystemWindows="true" />
|
||||
@@ -37,6 +37,8 @@
|
||||
|
||||
<import type="java.time.Duration" />
|
||||
|
||||
<import type="net.vonforst.evmap.api.fronyx.PredictionData" />
|
||||
|
||||
<variable
|
||||
name="charger"
|
||||
type="Resource<ChargeLocation>" />
|
||||
@@ -50,20 +52,8 @@
|
||||
type="Resource<ChargeLocationStatus>" />
|
||||
|
||||
<variable
|
||||
name="predictionGraph"
|
||||
type="Map<ZonedDateTime, Double>" />
|
||||
|
||||
<variable
|
||||
name="predictionMaxValue"
|
||||
type="Double" />
|
||||
|
||||
<variable
|
||||
name="predictionIsPercentage"
|
||||
type="Boolean" />
|
||||
|
||||
<variable
|
||||
name="predictionDescription"
|
||||
type="String" />
|
||||
name="predictionData"
|
||||
type="PredictionData" />
|
||||
|
||||
<variable
|
||||
name="filteredAvailability"
|
||||
@@ -367,11 +357,11 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@{predictionIsPercentage ? @string/average_utilization : @string/utilization_prediction}"
|
||||
android:text="@{predictionData.isPercentage ? @string/average_utilization : @string/utilization_prediction}"
|
||||
tools:text="@string/utilization_prediction"
|
||||
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
|
||||
android:textColor="?colorPrimary"
|
||||
app:goneUnless="@{predictionGraph != null}"
|
||||
app:goneUnless="@{predictionData.predictionGraph != null}"
|
||||
app:layout_constraintStart_toStartOf="@+id/guideline"
|
||||
app:layout_constraintTop_toBottomOf="@+id/divider2" />
|
||||
|
||||
@@ -381,9 +371,9 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="@{predictionDescription}"
|
||||
android:text="@{predictionData.description}"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
app:goneUnless="@{predictionGraph != null && !predictionIsPercentage}"
|
||||
app:goneUnless="@{predictionData.predictionGraph != null && !predictionData.isPercentage}"
|
||||
app:layout_constraintBaseline_toBaselineOf="@+id/textView8"
|
||||
app:layout_constraintEnd_toStartOf="@+id/btnPredictionHelp"
|
||||
app:layout_constraintStart_toEndOf="@+id/textView8"
|
||||
@@ -395,7 +385,7 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:contentDescription="@string/help"
|
||||
app:goneUnless="@{predictionGraph != null && !predictionIsPercentage}"
|
||||
app:goneUnless="@{predictionData.predictionGraph != null && !predictionData.isPercentage}"
|
||||
app:icon="@drawable/ic_help"
|
||||
app:iconTint="?android:textColorSecondary"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/textView8"
|
||||
@@ -407,13 +397,13 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="100dp"
|
||||
android:layout_marginTop="8dp"
|
||||
app:data="@{predictionGraph}"
|
||||
app:goneUnless="@{predictionGraph != null}"
|
||||
app:data="@{predictionData.predictionGraph}"
|
||||
app:goneUnless="@{predictionData.predictionGraph != null}"
|
||||
app:layout_constraintEnd_toStartOf="@+id/guideline2"
|
||||
app:layout_constraintStart_toStartOf="@+id/guideline"
|
||||
app:layout_constraintTop_toBottomOf="@+id/textView8"
|
||||
app:maxValue="@{predictionMaxValue}"
|
||||
app:isPercentage="@{predictionIsPercentage}"
|
||||
app:maxValue="@{predictionData.maxValue}"
|
||||
app:isPercentage="@{predictionData.isPercentage}"
|
||||
tools:itemCount="3"
|
||||
tools:layoutManager="LinearLayoutManager"
|
||||
tools:listitem="@layout/item_connector"
|
||||
@@ -427,7 +417,7 @@
|
||||
android:adjustViewBounds="true"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
android:scaleType="fitCenter"
|
||||
app:goneUnless="@{predictionGraph != null && !predictionIsPercentage}"
|
||||
app:goneUnless="@{predictionData.predictionGraph != null && !predictionData.isPercentage}"
|
||||
app:layout_constraintEnd_toStartOf="@+id/guideline2"
|
||||
app:layout_constraintTop_toBottomOf="@+id/prediction"
|
||||
app:srcCompat="@drawable/ic_powered_by_fronyx"
|
||||
@@ -439,7 +429,7 @@
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:background="?android:attr/listDivider"
|
||||
app:goneUnless="@{predictionGraph != null}"
|
||||
app:goneUnless="@{predictionData.predictionGraph != null}"
|
||||
app:layout_constraintTop_toBottomOf="@+id/imgPredictionSource" />
|
||||
|
||||
<ImageView
|
||||
|
||||
@@ -197,10 +197,7 @@
|
||||
app:charger="@{vm.charger}"
|
||||
app:availability="@{vm.availability}"
|
||||
app:filteredAvailability="@{vm.filteredAvailability}"
|
||||
app:predictionGraph="@{vm.predictionGraph}"
|
||||
app:predictionMaxValue="@{vm.predictionMaxValue}"
|
||||
app:predictionIsPercentage="@{vm.predictionIsPercentage}"
|
||||
app:predictionDescription="@{vm.predictionDescription}"
|
||||
app:predictionData="@{vm.predictionData}"
|
||||
app:chargeCards="@{vm.chargeCardMap}"
|
||||
app:filteredChargeCards="@{vm.filteredChargeCards}"
|
||||
app:distance="@{vm.chargerDistance}"
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
<action
|
||||
android:id="@+id/action_map_to_opensource_donations"
|
||||
app:destination="@id/opensource_donations" />
|
||||
<action
|
||||
android:id="@+id/action_map_to_data_settings"
|
||||
app:destination="@id/settings_data" />
|
||||
<argument
|
||||
android:name="locationName"
|
||||
android:defaultValue="@null"
|
||||
|
||||
@@ -275,6 +275,7 @@
|
||||
<string name="about_contributors">Mitwirkende</string>
|
||||
<string name="about_contributors_text">Dank an alle Mitwirkenden für ihre Beiträge von Code und Übersetzungen für EVMap:</string>
|
||||
<string name="utilization_prediction">Auslastungsprognose</string>
|
||||
<string name="powered_by_fronyx">powered by fronyx</string>
|
||||
<string name="prediction_help">Die Prognose basiert auf Faktoren wie Wochentag, Uhrzeit und Nutzung in der Vergangenheit. So kannst du stark ausgelastete Ladesäulen vermeiden. Keine Garantie.</string>
|
||||
<string name="prediction_time_colon">%s Uhr:</string>
|
||||
<plurals name="prediction_number_available">
|
||||
@@ -348,7 +349,7 @@
|
||||
<string name="auto_heading">Fahrtrichtung</string>
|
||||
<string name="auto_settings">Einstellungen</string>
|
||||
<string name="welcome_android_auto">Android Auto-Unterstützung</string>
|
||||
<string name="welcome_android_auto_detail">Auf unterstützen Autos kannst du EVMap auch mit Android Auto nutzen. Öffne dazu einfach die EVMap-App aus dem Menü von Android Auto.</string>
|
||||
<string name="welcome_android_auto_detail">Auf unterstützten Autos kannst du EVMap auch mit Android Auto nutzen. Öffne dazu einfach die EVMap-App aus dem Menü von Android Auto.</string>
|
||||
<string name="sounds_cool">Klingt cool</string>
|
||||
<string name="auto_chargeprice_vehicle_unavailable">EVMap konnte das Fahrzeugmodell nicht erkennen.</string>
|
||||
<string name="auto_chargeprice_vehicle_unknown">Keins der in der App ausgewählten Fahrzeuge passt zu diesem Fahrzeug (%1$s %2$s).</string>
|
||||
@@ -365,4 +366,5 @@
|
||||
<string name="referrals">Empfehlungslinks</string>
|
||||
<string name="referrals_info">Du kannst auch einen der Empfehlungslinks unten benutzen, um den Entwickler mit deinem Kauf zu unterstützen.</string>
|
||||
<string name="referral_tesla">Tesla</string>
|
||||
<string name="generic_connection_error">Daten konnten nicht geladen werden</string>
|
||||
</resources>
|
||||
@@ -331,4 +331,18 @@
|
||||
<string name="loading">Laster inn …</string>
|
||||
<string name="auto_multipage_goto">Side %d</string>
|
||||
<string name="auto_multipage">(%d/%d)</string>
|
||||
<string name="charge_price_minute_format">%2$s%1$.2f/min</string>
|
||||
<string name="website">Nettside</string>
|
||||
<string name="pref_tesla_account_enabled">Innlogget som %s</string>
|
||||
<string name="pref_units">Enheter</string>
|
||||
<string name="log_out">Logg ut</string>
|
||||
<string name="logging_in">Logger inn …</string>
|
||||
<string name="realtime_data_login_needed">Tesla-konto kreves for sanntidsdata</string>
|
||||
<string name="generic_connection_error">Kunne ikke laste inn data</string>
|
||||
<string name="logged_out">Utlogget</string>
|
||||
<string name="pref_tesla_account">Tesla-konto</string>
|
||||
<string name="tesla_pricing_others">Andre kunder:</string>
|
||||
<string name="referral_tesla">Tesla</string>
|
||||
<string name="pref_units_default">Enhetsforvalg</string>
|
||||
<string name="login">Logg inn</string>
|
||||
</resources>
|
||||
@@ -362,4 +362,14 @@
|
||||
<string name="sounds_cool">Continuar</string>
|
||||
<string name="reload">Recarregar</string>
|
||||
<string name="accept_privacy"><![CDATA[Li e aceito a <a href=\"%s\">política de privacidade</a> do EVMap.]]></string>
|
||||
<string name="referrals">Links de afiliado</string>
|
||||
<string name="referral_tesla">Tesla</string>
|
||||
<string name="pref_units">Unidades</string>
|
||||
<string name="pref_map_scale_meters_and_miles">Milhas e metros na barra de escala do mapa</string>
|
||||
<string name="pref_units_default">Padrão do dispositivo</string>
|
||||
<string name="pref_units_metric">Métrico</string>
|
||||
<string name="pref_units_imperial">Imperial</string>
|
||||
<string name="referrals_info">Também pode usar um dos seguintes links de afiliado para apoiar o desenvolvedor da app com a sua compra.</string>
|
||||
<string name="generic_connection_error">Não foi possível carregar a informação</string>
|
||||
<string name="powered_by_fronyx">previsão feita pelo fronyx</string>
|
||||
</resources>
|
||||
@@ -275,6 +275,7 @@
|
||||
<string name="about_contributors">Contributors</string>
|
||||
<string name="about_contributors_text">Thanks to all contributors for their coding and translation contributions to EVMap:</string>
|
||||
<string name="utilization_prediction">Utilization prediction</string>
|
||||
<string name="powered_by_fronyx">powered by fronyx</string>
|
||||
<string name="prediction_help">The prediction is based on factors like day of the week, time of day and past usage, so that you can avoid overcrowded chargers. No guarantee.</string>
|
||||
<string name="prediction_time_colon">%s:</string>
|
||||
<plurals name="prediction_number_available">
|
||||
@@ -304,7 +305,7 @@
|
||||
<string name="logging_in">Logging in…</string>
|
||||
<string name="log_out">Log out</string>
|
||||
<string name="logged_out">Logged out</string>
|
||||
<string name="login">Login</string>
|
||||
<string name="login">Log in</string>
|
||||
<string name="login_error">Login failed</string>
|
||||
<string name="tesla_pricing_owners">Tesla vehicles only:</string>
|
||||
<string name="tesla_pricing_members">Tesla vehicles & members:</string>
|
||||
@@ -365,4 +366,5 @@
|
||||
<string name="referrals">Referral links</string>
|
||||
<string name="referrals_info">You can also use one of the referral links below to support the developer with your purchase.</string>
|
||||
<string name="referral_tesla">Tesla</string>
|
||||
<string name="generic_connection_error">Could not load data</string>
|
||||
</resources>
|
||||
@@ -3,7 +3,7 @@
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.9.0'
|
||||
ext.about_libs_version = '8.9.4'
|
||||
ext.nav_version = '2.7.1'
|
||||
ext.nav_version = '2.7.2'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
|
||||
5
fastlane/metadata/android/de-DE/changelogs/200.txt
Normal file
5
fastlane/metadata/android/de-DE/changelogs/200.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
Verbesserungen:
|
||||
- Beim Start der App wird nun der zuletzt gesehene Kartenausschnitt gezeigt
|
||||
|
||||
Fehler behoben:
|
||||
- Abstürze behoben
|
||||
5
fastlane/metadata/android/de-DE/changelogs/202.txt
Normal file
5
fastlane/metadata/android/de-DE/changelogs/202.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
Neue Funktionen:
|
||||
- Auslastungsprognose auch unter Android Auto verfügbar
|
||||
|
||||
Fehler behoben:
|
||||
- Abstürze behoben
|
||||
5
fastlane/metadata/android/en-US/changelogs/200.txt
Normal file
5
fastlane/metadata/android/en-US/changelogs/200.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
Improvements:
|
||||
- When starting the app, the last viewed map area will be shown
|
||||
|
||||
Bugfixes:
|
||||
- Fixed crashes
|
||||
5
fastlane/metadata/android/en-US/changelogs/202.txt
Normal file
5
fastlane/metadata/android/en-US/changelogs/202.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
New features:
|
||||
- Availability prediction also available on Android Auto
|
||||
|
||||
Bugfixes:
|
||||
- Fixed crashes
|
||||
Reference in New Issue
Block a user