diff --git a/app/src/google/AndroidManifest.xml b/app/src/google/AndroidManifest.xml
index cda57b0e..bfed96af 100644
--- a/app/src/google/AndroidManifest.xml
+++ b/app/src/google/AndroidManifest.xml
@@ -6,6 +6,7 @@
+
diff --git a/app/src/google/java/net/vonforst/evmap/auto/FilterScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/FilterScreen.kt
index cae3a35b..d6a51886 100644
--- a/app/src/google/java/net/vonforst/evmap/auto/FilterScreen.kt
+++ b/app/src/google/java/net/vonforst/evmap/auto/FilterScreen.kt
@@ -26,15 +26,11 @@ class FilterScreen(ctx: CarContext) : Screen(ctx) {
init {
val size = (ctx.resources.displayMetrics.density * 24).roundToInt()
- emptyIcon = CarIcon.Builder(
- IconCompat.createWithBitmap(
- Bitmap.createBitmap(
- size,
- size,
- Bitmap.Config.ARGB_8888
- )
- )
- ).build()
+ emptyIcon = Bitmap.createBitmap(
+ size,
+ size,
+ Bitmap.Config.ARGB_8888
+ ).asCarIcon()
}
init {
diff --git a/app/src/google/java/net/vonforst/evmap/auto/Utils.kt b/app/src/google/java/net/vonforst/evmap/auto/Utils.kt
index e14a2b38..69b16723 100644
--- a/app/src/google/java/net/vonforst/evmap/auto/Utils.kt
+++ b/app/src/google/java/net/vonforst/evmap/auto/Utils.kt
@@ -1,9 +1,14 @@
package net.vonforst.evmap.auto
+import android.graphics.Bitmap
import androidx.car.app.CarContext
import androidx.car.app.constraints.ConstraintManager
+import androidx.car.app.hardware.common.CarUnit
import androidx.car.app.model.CarColor
+import androidx.car.app.model.CarIcon
+import androidx.core.graphics.drawable.IconCompat
import net.vonforst.evmap.api.availability.ChargepointStatus
+import java.util.*
fun carAvailabilityColor(status: List): CarColor {
val unknown = status.any { it == ChargepointStatus.UNKNOWN }
@@ -22,4 +27,45 @@ fun carAvailabilityColor(status: List): CarColor {
}
val CarContext.constraintManager
- get() = getCarService(CarContext.CONSTRAINT_SERVICE) as ConstraintManager
\ No newline at end of file
+ get() = getCarService(CarContext.CONSTRAINT_SERVICE) as ConstraintManager
+
+fun Bitmap.asCarIcon(): CarIcon = CarIcon.Builder(IconCompat.createWithBitmap(this)).build()
+
+private const val kmPerMile = 1.609344
+
+fun getDefaultDistanceUnit(): Int {
+ return when (Locale.getDefault().country) {
+ "US", "GB", "MM", "LR" -> CarUnit.MILE
+ else -> CarUnit.KILOMETER
+ }
+}
+
+fun getDefaultSpeedUnit(): Int {
+ return when (Locale.getDefault().country) {
+ "US", "GB", "MM", "LR" -> CarUnit.MILES_PER_HOUR
+ else -> CarUnit.KILOMETERS_PER_HOUR
+ }
+}
+
+fun formatCarUnitDistance(value: Float?, unit: Int?): String {
+ if (value == null) return ""
+ return when (unit ?: getDefaultDistanceUnit()) {
+ // distance units: base unit is meters
+ CarUnit.METER -> "%.0f m".format(value)
+ CarUnit.KILOMETER -> "%.1f km".format(value / 1000)
+ CarUnit.MILLIMETER -> "%.0f mm".format(value * 1000) // whoever uses that...
+ CarUnit.MILE -> "%.1f mi".format(value / 1000 / kmPerMile)
+ else -> ""
+ }
+}
+
+fun formatCarUnitSpeed(value: Float?, unit: Int?): String {
+ if (value == null) return ""
+ return when (unit ?: getDefaultSpeedUnit()) {
+ // speed units: base unit is meters per second
+ CarUnit.METERS_PER_SEC -> "%.0f m/s".format(value)
+ CarUnit.KILOMETERS_PER_HOUR -> "%.0f km/h".format(value * 3.6)
+ CarUnit.MILES_PER_HOUR -> "%.0f mph".format(value * 3.6 / kmPerMile)
+ else -> ""
+ }
+}
\ No newline at end of file
diff --git a/app/src/google/java/net/vonforst/evmap/auto/VehicleDataScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/VehicleDataScreen.kt
index 401d2185..01404500 100644
--- a/app/src/google/java/net/vonforst/evmap/auto/VehicleDataScreen.kt
+++ b/app/src/google/java/net/vonforst/evmap/auto/VehicleDataScreen.kt
@@ -8,17 +8,28 @@ import androidx.car.app.Screen
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.info.EnergyLevel
import androidx.car.app.hardware.info.Model
+import androidx.car.app.hardware.info.Speed
import androidx.car.app.model.*
import androidx.core.content.ContextCompat
+import androidx.core.graphics.drawable.IconCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.OnLifecycleEvent
import net.vonforst.evmap.R
+import net.vonforst.evmap.ui.Gauge
+import kotlin.math.min
+import kotlin.math.roundToInt
class VehicleDataScreen(ctx: CarContext) : Screen(ctx) {
private val hardwareMan = ctx.getCarService(CarContext.HARDWARE_SERVICE) as CarHardwareManager
private var model: Model? = null
private var energyLevel: EnergyLevel? = null
+ private var speed: Speed? = null
+ private var gauge = Gauge((ctx.resources.displayMetrics.density * 128).roundToInt(), ctx)
+ private val maxSpeed = 160f / 3.6f // m/s, speed gauge will show max if speed is higher
private val permissions = listOf(
- "com.google.android.gms.permission.CAR_FUEL"
+ "com.google.android.gms.permission.CAR_FUEL",
+ "com.google.android.gms.permission.CAR_SPEED"
)
override fun onGetTemplate(): Template {
@@ -29,7 +40,7 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx) {
screenManager.pushForResult(
PermissionScreen(
carContext,
- R.string.auto_location_permission_needed,
+ R.string.auto_vehicle_data_permission_needed,
permissions
)
) {
@@ -40,6 +51,7 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx) {
val energyLevel = energyLevel
val model = model
+ val speed = speed
return GridTemplate.Builder().apply {
setTitle(
@@ -55,36 +67,122 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx) {
} else {
setSingleList(
ItemList.Builder().apply {
- addItem(
- GridItem.Builder().apply {
- setTitle("Battery")
- energyLevel?.batteryPercent?.value?.let { percent ->
- setText("%.1f".format(percent))
- setImage(CarIcon.APP_ICON)
- } ?: setLoading(true)
- }.build()
- )
- addItem(
- GridItem.Builder().apply {
- setTitle("Fuel")
- energyLevel?.fuelPercent?.value?.let { percent ->
- setText("%.1f".format(percent))
- setImage(CarIcon.APP_ICON)
- } ?: setLoading(true)
- }.build()
- )
+ addItem(GridItem.Builder().apply {
+ setTitle(carContext.getString(R.string.auto_charging_level))
+ if (energyLevel == null) {
+ setLoading(true)
+ } else if (energyLevel.batteryPercent.value != null && energyLevel.fuelPercent.value != null) {
+ // both battery and fuel (Plug-in hybrid)
+ setText(
+ "\uD83D\uDD0C %.0f %% ⛽ %.0f %%".format(
+ energyLevel.batteryPercent.value,
+ energyLevel.fuelPercent.value
+ )
+ )
+ setImage(
+ gauge.draw(
+ energyLevel.batteryPercent.value,
+ energyLevel.fuelPercent.value
+ ).asCarIcon()
+ )
+ } else if (energyLevel.batteryPercent.value != null) {
+ // BEV
+ setText("%.0f %%".format(energyLevel.batteryPercent.value))
+ setImage(gauge.draw(energyLevel.batteryPercent.value).asCarIcon())
+ } else if (energyLevel.fuelPercent.value != null) {
+ // ICE
+ setText("⛽ %.0f %%".format(energyLevel.fuelPercent.value))
+ setImage(gauge.draw(energyLevel.fuelPercent.value).asCarIcon())
+ } else {
+ setText(carContext.getString(R.string.auto_no_data))
+ setImage(gauge.draw(0f).asCarIcon())
+ }
+ }.build())
+ addItem(GridItem.Builder().apply {
+ setTitle(carContext.getString(R.string.auto_range))
+ if (energyLevel == null) {
+ setLoading(true)
+ } else if (energyLevel.rangeRemainingMeters.value != null) {
+ setText(
+ formatCarUnitDistance(
+ energyLevel.rangeRemainingMeters.value,
+ energyLevel.distanceDisplayUnit.value
+ )
+ )
+ setImage(
+ CarIcon.Builder(
+ IconCompat.createWithResource(
+ carContext,
+ R.drawable.ic_car
+ )
+ ).build()
+ )
+ } else {
+ setText(carContext.getString(R.string.auto_no_data))
+ setImage(
+ CarIcon.Builder(
+ IconCompat.createWithResource(
+ carContext,
+ R.drawable.ic_car
+ )
+ ).build()
+ )
+ }
+ }.build())
+ addItem(GridItem.Builder().apply {
+ setTitle(carContext.getString(R.string.auto_speed))
+ if (speed == null) {
+ setLoading(true)
+ } else {
+ val rawSpeed = speed.rawSpeedMetersPerSecond.value
+ val displaySpeed = speed.displaySpeedMetersPerSecond.value
+ if (rawSpeed != null) {
+ setText(
+ formatCarUnitSpeed(
+ rawSpeed,
+ speed.speedDisplayUnit.value
+ )
+ )
+ setImage(
+ gauge.draw(min(rawSpeed / maxSpeed * 100, 100f)).asCarIcon()
+ )
+ } else if (displaySpeed != null) {
+ setText(
+ formatCarUnitSpeed(
+ speed.displaySpeedMetersPerSecond.value,
+ speed.speedDisplayUnit.value
+ )
+ )
+ setImage(
+ gauge.draw(min(displaySpeed / maxSpeed * 100, 100f))
+ .asCarIcon()
+ )
+ } else {
+ setText(carContext.getString(R.string.auto_no_data))
+ setImage(gauge.draw(0f).asCarIcon())
+ }
+ }
+ }.build())
}.build()
)
}
}.build()
}
+ private fun onEnergyLevelUpdated(energyLevel: EnergyLevel) {
+ this.energyLevel = energyLevel
+ invalidate()
+ }
+
+ private fun onSpeedUpdated(speed: Speed) {
+ this.speed = speed
+ invalidate()
+ }
+
private fun setupListeners() {
val exec = ContextCompat.getMainExecutor(carContext)
- hardwareMan.carInfo.addEnergyLevelListener(exec) {
- this.energyLevel = it
- invalidate()
- }
+ hardwareMan.carInfo.addEnergyLevelListener(exec, ::onEnergyLevelUpdated)
+ hardwareMan.carInfo.addSpeedListener(exec, ::onSpeedUpdated)
hardwareMan.carInfo.fetchModel(exec) {
this.model = it
@@ -92,6 +190,12 @@ class VehicleDataScreen(ctx: CarContext) : Screen(ctx) {
}
}
+ @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
+ private fun removeListeners() {
+ hardwareMan.carInfo.removeEnergyLevelListener(::onEnergyLevelUpdated)
+ hardwareMan.carInfo.removeSpeedListener(::onSpeedUpdated)
+ }
+
private fun permissionsGranted(): Boolean =
permissions.all {
ContextCompat.checkSelfPermission(
diff --git a/app/src/google/java/net/vonforst/evmap/ui/Gauge.kt b/app/src/google/java/net/vonforst/evmap/ui/Gauge.kt
new file mode 100644
index 00000000..f0bb93f6
--- /dev/null
+++ b/app/src/google/java/net/vonforst/evmap/ui/Gauge.kt
@@ -0,0 +1,74 @@
+package net.vonforst.evmap.ui
+
+import android.content.Context
+import android.graphics.*
+import androidx.annotation.ColorInt
+import androidx.core.content.ContextCompat
+import net.vonforst.evmap.R
+import kotlin.math.max
+import kotlin.math.min
+
+class Gauge(val size: Int, ctx: Context) {
+ val arcPaint = Paint().apply {
+ style = Paint.Style.STROKE
+ strokeWidth = size * 0.15f
+ }
+ val gaugePaint = Paint()
+ val activeColor = ContextCompat.getColor(ctx, R.color.gauge_active)
+ val middleColor = ContextCompat.getColor(ctx, R.color.gauge_middle)
+ val inactiveColor = ContextCompat.getColor(ctx, R.color.gauge_inactive)
+
+ fun draw(valuePercent: Float?, secondValuePercent: Float? = null): Bitmap {
+ val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(bitmap)
+
+ canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
+
+ val angle = valuePercent?.let { 180f * it / 100 } ?: 0f
+ val secondAngle = secondValuePercent?.let { 180f * it / 100 }
+
+ drawArc(angle, secondAngle, canvas)
+ if (secondAngle != null) drawGauge(secondAngle, inactiveColor, canvas)
+ drawGauge(angle, Color.WHITE, canvas)
+ return bitmap
+ }
+
+ private fun drawGauge(angle: Float, @ColorInt color: Int, canvas: Canvas) {
+ gaugePaint.color = color
+ canvas.save()
+ canvas.rotate(angle - 90, size / 2f, 3 * size / 4f)
+ canvas.drawCircle(size / 2f, 3 * size / 4f, size * 0.1F, gaugePaint)
+ canvas.drawRect(size * 0.48f, 3 * size / 4f, size * 0.53f, size * 0.325f, gaugePaint)
+ canvas.restore()
+ }
+
+ private fun drawArc(angle: Float, secondAngle: Float?, canvas: Canvas) {
+ val (angle1, angle2) = if (secondAngle != null) {
+ min(angle, secondAngle) to max(angle, secondAngle)
+ } else {
+ angle to null
+ }
+
+ arcPaint.color = activeColor
+ val arcBounds = RectF(
+ arcPaint.strokeWidth / 2,
+ size / 4f + arcPaint.strokeWidth / 2,
+ size - arcPaint.strokeWidth / 2,
+ 5 * size / 4f - arcPaint.strokeWidth / 2
+ )
+
+ canvas.drawArc(arcBounds, 180f, angle1, false, arcPaint)
+ if (angle2 != null) {
+ arcPaint.color = middleColor
+ canvas.drawArc(arcBounds, 180f + angle1, angle2 - angle1, false, arcPaint)
+ }
+ arcPaint.color = inactiveColor
+ canvas.drawArc(
+ arcBounds,
+ 180f + (angle2 ?: angle1),
+ 180f - (angle2 ?: angle1),
+ false,
+ arcPaint
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/google/res/values-de/values.xml b/app/src/google/res/values-de/values.xml
index 3f5225cd..ab60fd0a 100644
--- a/app/src/google/res/values-de/values.xml
+++ b/app/src/google/res/values-de/values.xml
@@ -22,6 +22,10 @@
⚠️ Störungsmeldung (%s)
Weitere Aktualisierung nicht möglich. Bitte zurück gehen und neu starten.
Fahrzeugdaten
+ Ladezustand
+ Nicht verfügbar
+ Reichweite
+ Geschwindigkeit
Android Auto-Unterstützung
Auf unterstützen Autos kannst du EVMap auch mit Android Auto nutzen. Öffne dazu einfach die EVMap-App aus dem Menü von Android Auto.
klingt cool
diff --git a/app/src/google/res/values/colors.xml b/app/src/google/res/values/colors.xml
new file mode 100644
index 00000000..34fdac36
--- /dev/null
+++ b/app/src/google/res/values/colors.xml
@@ -0,0 +1,6 @@
+
+
+ #00e676
+ #087f23
+ #9e9e9e
+
\ No newline at end of file
diff --git a/app/src/google/res/values/values.xml b/app/src/google/res/values/values.xml
index 101b3dff..d4b7b014 100644
--- a/app/src/google/res/values/values.xml
+++ b/app/src/google/res/values/values.xml
@@ -32,6 +32,10 @@
⚠️ Fault report (%s)
Further updates not possible. Please go back and restart.
Vehicle data
+ Charging level
+ Unavailable
+ Range
+ Speed
Android Auto support
You can also use EVMap from within Android Auto on supported cars. Simply select the EVMap app in the Android Auto menu.
sounds cool