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