diff --git a/app/src/main/java/com/openvehicles/OVMS/ui/settings/CarEditorFragment.kt b/app/src/main/java/com/openvehicles/OVMS/ui/settings/CarEditorFragment.kt index b56f7d74..3dc98ac8 100644 --- a/app/src/main/java/com/openvehicles/OVMS/ui/settings/CarEditorFragment.kt +++ b/app/src/main/java/com/openvehicles/OVMS/ui/settings/CarEditorFragment.kt @@ -361,6 +361,7 @@ class CarEditorFragment : BaseFragment() { "car_smart_44_white_silver", "car_smart_44_fl_black", "car_zoe_black", + "car_egolf_white", "car_vwup_black", "car_vwup_blue", "car_vwup_red", diff --git a/app/src/main/java/com/openvehicles/OVMS/ui2/pages/ClimateFragment.kt b/app/src/main/java/com/openvehicles/OVMS/ui2/pages/ClimateFragment.kt index da003e86..7cd1d09d 100644 --- a/app/src/main/java/com/openvehicles/OVMS/ui2/pages/ClimateFragment.kt +++ b/app/src/main/java/com/openvehicles/OVMS/ui2/pages/ClimateFragment.kt @@ -10,9 +10,11 @@ import android.view.ViewGroup import android.view.animation.Animation import android.view.animation.AnimationUtils import android.widget.ImageView +import android.widget.LinearLayout import android.widget.TextView import android.widget.Toast import androidx.core.content.ContextCompat +import com.google.android.material.slider.Slider import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.openvehicles.OVMS.R @@ -33,6 +35,14 @@ class ClimateFragment : BaseFragment(), OnResultCommandListener { private lateinit var climateActionsAdapter: QuickActionsAdapter + /** + * Right-hand button column. Used only where the card carries the target + * temperature slider (VW e-Golf): the start button sits there because that + * is where the thumb falls with the phone in the right hand. Everywhere + * else it stays empty and gone, so those vehicles keep the layout they had. + */ + private lateinit var climateActionsRightAdapter: QuickActionsAdapter + @@ -52,10 +62,80 @@ class ClimateFragment : BaseFragment(), OnResultCommandListener { climateActionsRecyclerView.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false) climateActionsRecyclerView.adapter = climateActionsAdapter + val rightRecyclerView = findViewById(R.id.climateActionsRight) as RecyclerView + climateActionsRightAdapter = QuickActionsAdapter(context) + rightRecyclerView.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false) + rightRecyclerView.adapter = climateActionsRightAdapter + + initialiseTargetTempSlider() initialiseCarRendering(carData) initialiseClimateControls(carData) } + /** + * Target temperature for pre-conditioning. + * + * Only shown for vehicles whose module can set it — currently the VW e-Golf, + * where the value lives in the car's stored BatteryControl profile and the + * module writes it back with `xvg cctemp`. Set up once here rather than in + * [initialiseClimateControls], which runs again on every data update and + * would otherwise stack listeners. + */ + private fun initialiseTargetTempSlider() { + val group = findViewById(R.id.ccTempGroup) as LinearLayout + if (carData?.car_type != "VWEG") { + group.visibility = View.GONE + return + } + group.visibility = View.VISIBLE + + val slider = findViewById(R.id.ccTempSlider) as Slider + slider.addOnChangeListener { _, value, _ -> showTargetTemp(value) } + slider.addOnSliderTouchListener(object : Slider.OnSliderTouchListener { + override fun onStartTrackingTouch(s: Slider) {} + override fun onStopTrackingTouch(s: Slider) { + // Send on release only — sending while dragging would put a write + // on the car's comfort bus for every step. + sendCommand( + getString(R.string.climate_target_temp, formatTemp(s.value)), + "7,xvg cctemp " + formatTemp(s.value), + this@ClimateFragment + ) + } + }) + showTargetTemp(slider.value) + + // The target temperature is not carried by the v2 protocol, so it cannot + // come in with the metrics — ask the module for it when the tab opens. + sendCommand("", "7,xvg ccstatus", this) + } + + private fun formatTemp(value: Float): String = DecimalFormat("0.0").format(value) + + private fun showTargetTemp(value: Float) { + val label = findViewById(R.id.ccTempLabel) as TextView + // Just the value — the slider directly beneath it makes clear what it is. + label.text = formatTemp(value) + " °C" + } + + /** + * Applies `cctemp=22.0 current=32 valid=1` as reported by the module. + * + * `valid=0` means the module has not read the car's profile yet, so the + * value carries no information — leave the slider where it is rather than + * snapping it to a placeholder. + */ + private fun applyClimateStatus(text: String) { + if (Regex("valid=0").containsMatchIn(text)) return + Regex("cctemp=([0-9.]+)").find(text)?.groupValues?.get(1)?.toFloatOrNull()?.let { + val slider = findViewById(R.id.ccTempSlider) as Slider + if (it >= slider.valueFrom && it <= slider.valueTo) { + slider.value = it + showTargetTemp(it) + } + } + } + private fun initialiseCarRendering(carData: CarData?) { val carImageView = findViewById(R.id.battIndicatorImg) as ImageView val layers = carData?.let { CarRenderingUtils.getTopDownCarLayers(it, requireContext(), climate = true, heat = carData.car_hvac_on) } @@ -167,13 +247,32 @@ class ClimateFragment : BaseFragment(), OnResultCommandListener { climateActionsAdapter.mData.clear() climateActionsAdapter.setCarData(carData) - climateActionsAdapter.mData += ClimateQuickAction({getService()}) + climateActionsRightAdapter.mData.clear() + climateActionsRightAdapter.setCarData(carData) + + val leftColumn = findViewById(R.id.climateActions) as RecyclerView + val rightColumn = findViewById(R.id.climateActionsRight) as RecyclerView + if (carData?.car_type == "VWEG") { + // Start on the right, where the thumb falls with the phone in the right + // hand. There is deliberately no "climatise without the cable" button: + // that profile bit is owned by the module, which sets it for a climate + // command and clears it for a charge — a user-facing toggle would fight + // the firmware and show a state that changes under the user's hands. + climateActionsRightAdapter.mData += ClimateQuickAction({getService()}) + leftColumn.visibility = View.GONE + rightColumn.visibility = View.VISIBLE + } else { + climateActionsAdapter.mData += ClimateQuickAction({getService()}) + leftColumn.visibility = View.VISIBLE + rightColumn.visibility = View.GONE + } if (carData?.car_type in listOf("NL","SE","SQ","VWUP","VWUP.T26","RZ","RZ2") || carData?.car_type.orEmpty().startsWith("VA") || carData?.car_type.orEmpty().startsWith("VB") || carData?.car_type.orEmpty().startsWith("OAE")) climateActionsAdapter.mData += ClimateScheduleQuickAction({getService()}) climateActionsAdapter.notifyDataSetChanged() + climateActionsRightAdapter.notifyDataSetChanged() } override fun update(carData: CarData?) { @@ -187,6 +286,25 @@ class ClimateFragment : BaseFragment(), OnResultCommandListener { val resCode = result[1].toInt() val resText = if (result.size > 2) result[2] else "" val cmdMessage = getSentCommandMessage(result[0]) + // Status reply from `xvg ccstatus`: sync slider and button. + if (resCode == 0 && resText.contains("cctemp=")) { + applyClimateStatus(resText) + cancelCommand() + return + } + // Anything else we sent for this car changes the stored profile, and the + // module may well have refused it — a sleeping car cannot be written to. + // Never leave the slider showing a value the car does not hold: ask what + // it actually is now. The reply lands in the branch above. + // result[0] is the command *code*, not the text we sent — BaseFragment + // keys its message map on command.split(",")[0]. 7 is "execute command", + // and for this vehicle the only ones this tab sends are the xvg writes. + if (carData?.car_type == "VWEG" && result[0] == "7") { + if (resText.isNotEmpty()) + Toast.makeText(activity, resText, Toast.LENGTH_LONG).show() + sendCommand("", "7,xvg ccstatus", this) + return + } val context: Context? = activity if (context != null) { when (resCode) { diff --git a/app/src/main/java/com/openvehicles/OVMS/ui2/pages/settings/CarEditorFragment.kt b/app/src/main/java/com/openvehicles/OVMS/ui2/pages/settings/CarEditorFragment.kt index 7dafc9ee..900da31f 100644 --- a/app/src/main/java/com/openvehicles/OVMS/ui2/pages/settings/CarEditorFragment.kt +++ b/app/src/main/java/com/openvehicles/OVMS/ui2/pages/settings/CarEditorFragment.kt @@ -102,6 +102,7 @@ class CarEditorFragment : BaseFragment() { VehicleType("smart_eq", "Smart EQ (ForTwo)", listOf("red", "black", "white", "fl_black", "fl_white", "fl_red", "cabrio_black", "cabrio_crystalwhite", "cabrio_grey", "cabrio_lavaorange")), VehicleType("smart_44", "Smart ForFour", listOf("black", "white_silver", "fl_black")), VehicleType("vwup", "VW e-Up", listOf("black", "blue", "red", "silver", "white", "yellow")), + VehicleType("egolf", "VW e-Golf", listOf("white")), VehicleType("zoe", "Renault Zoe", listOf("black", "brown", "grey", "hellblau", "lila", "red", "white", "ytriumgrau")), VehicleType("mgzs", "MG ZS EV", listOf("white", "blue", "lightblue", "red", "black")), VehicleType("edeliver3", "Maxus eDeliver 3", listOf("white")), @@ -818,6 +819,7 @@ class CarEditorFragment : BaseFragment() { "map_car_twizy_snowwhiteandflameorange", "map_car_twizy_snowwhiteandurbanblue", "map_car_twizy_snowwhitewithblack", + "map_car_egolf_white", "map_car_vwup_black", "map_car_vwup_blue", "map_car_vwup_red", @@ -897,6 +899,7 @@ class CarEditorFragment : BaseFragment() { "car_thinkcity_classicblack", "car_thinkcity_skyblue", "car_twizy", + "car_egolf_white", "car_vwup_black", "car_vwup_blue", "car_vwup_red", @@ -982,6 +985,7 @@ class CarEditorFragment : BaseFragment() { "car_smart_44_white_silver", "car_smart_44_fl_black", "car_zoe_black", + "car_egolf_white", "car_vwup_black", "car_vwup_blue", "car_vwup_red", diff --git a/app/src/main/java/com/openvehicles/OVMS/ui2/rendering/CarRenderingUtils.kt b/app/src/main/java/com/openvehicles/OVMS/ui2/rendering/CarRenderingUtils.kt index 96f042b3..8f690676 100644 --- a/app/src/main/java/com/openvehicles/OVMS/ui2/rendering/CarRenderingUtils.kt +++ b/app/src/main/java/com/openvehicles/OVMS/ui2/rendering/CarRenderingUtils.kt @@ -1,6 +1,10 @@ package com.openvehicles.OVMS.ui2.rendering import android.content.Context +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.PixelFormat +import android.graphics.Rect import android.graphics.drawable.Drawable import android.graphics.drawable.Animatable import android.graphics.drawable.VectorDrawable @@ -310,18 +314,82 @@ object CarRenderingUtils { finalDrawable.start() } - layers = layers.plus(finalDrawable) + layers = layers.plus(fitToBase(finalDrawable, layers.first())) } else { Log.e("DrawableError", "Could not load R.drawable.avd_animated_ac_arrows") val staticArrows = ContextCompat.getDrawable(context, R.drawable.topview_ac_arrows) if (staticArrows != null) { val vectorDrawable = staticArrows.mutate() as VectorDrawable vectorDrawable.setTint(tintColor) - layers = layers.plus(vectorDrawable) + layers = layers.plus(fitToBase(vectorDrawable, layers.first())) } } } return layers } + + /** + * The AC arrows are a vector sized in dp and drawn for the Leaf's 320x560 top + * view. Every other car image is a nodpi bitmap, so the arrows come out far + * bigger in pixels, the LayerDrawable takes their size as its own, and the car + * underneath gets stretched to their aspect ratio. Reporting the base image's + * size keeps the composite unchanged whether the overlay is there or not. + */ + private fun fitToBase(overlay: Drawable, base: Drawable): Drawable { + if (base.intrinsicWidth <= 0 || base.intrinsicHeight <= 0) return overlay + return FittedOverlayDrawable(overlay, base.intrinsicWidth, base.intrinsicHeight) + } + + private class FittedOverlayDrawable( + private val inner: Drawable, + private val width: Int, + private val height: Int + ) : Drawable(), Drawable.Callback { + + init { + inner.callback = this + } + + override fun getIntrinsicWidth() = width + + override fun getIntrinsicHeight() = height + + override fun onBoundsChange(bounds: Rect) { + // Fit centred, keeping the overlay's own aspect ratio, so the arrows + // stay over the cabin instead of being stretched across the car. + val iw = if (inner.intrinsicWidth > 0) inner.intrinsicWidth else bounds.width() + val ih = if (inner.intrinsicHeight > 0) inner.intrinsicHeight else bounds.height() + if (iw <= 0 || ih <= 0) { + inner.bounds = bounds + return + } + val scale = minOf(bounds.width() / iw.toFloat(), bounds.height() / ih.toFloat()) + val w = (iw * scale).toInt() + val h = (ih * scale).toInt() + val left = bounds.left + (bounds.width() - w) / 2 + val top = bounds.top + (bounds.height() - h) / 2 + inner.setBounds(left, top, left + w, top + h) + } + + override fun draw(canvas: Canvas) = inner.draw(canvas) + + override fun setAlpha(alpha: Int) { + inner.alpha = alpha + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + inner.colorFilter = colorFilter + } + + @Deprecated("Deprecated in Drawable", ReplaceWith("PixelFormat.TRANSLUCENT")) + override fun getOpacity() = PixelFormat.TRANSLUCENT + + override fun invalidateDrawable(who: Drawable) = invalidateSelf() + + override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) = + scheduleSelf(what, `when`) + + override fun unscheduleDrawable(who: Drawable, what: Runnable) = unscheduleSelf(what) + } } \ No newline at end of file diff --git a/app/src/main/res/drawable-nodpi/car_egolf_white.png b/app/src/main/res/drawable-nodpi/car_egolf_white.png new file mode 100644 index 00000000..f670bda0 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/car_egolf_white.png differ diff --git a/app/src/main/res/drawable-nodpi/map_car_egolf_white.png b/app/src/main/res/drawable-nodpi/map_car_egolf_white.png new file mode 100644 index 00000000..3cb13b34 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/map_car_egolf_white.png differ diff --git a/app/src/main/res/drawable-nodpi/ol_car_egolf_white.png b/app/src/main/res/drawable-nodpi/ol_car_egolf_white.png new file mode 100644 index 00000000..44bf02bf Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ol_car_egolf_white.png differ diff --git a/app/src/main/res/layout/fragment_climate.xml b/app/src/main/res/layout/fragment_climate.xml index 1d1fcbff..2d1cd9b0 100644 --- a/app/src/main/res/layout/fragment_climate.xml +++ b/app/src/main/res/layout/fragment_climate.xml @@ -12,9 +12,9 @@ android:layout_width="0dp" android:layout_height="0dp" android:layout_marginStart="20dp" - android:layout_marginTop="50dp" + android:layout_marginTop="12dp" android:layout_marginEnd="20dp" - android:layout_marginBottom="24dp" + android:layout_marginBottom="8dp" app:layout_constraintBottom_toTopOf="@+id/climate_card" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -36,51 +36,91 @@ android:layout_height="wrap_content" android:padding="12dp"> + + + + + + + app:layout_constraintBottom_toBottomOf="@+id/ambientTemp" + app:layout_constraintStart_toEndOf="@+id/ambientTemp" + app:layout_constraintTop_toTopOf="@+id/ambientTemp" + app:layout_constraintVertical_bias="0.26999998" + tools:text="°C" /> + app:layout_constraintStart_toStartOf="@+id/ambientTemp" + app:layout_constraintTop_toBottomOf="@+id/ambientTemp" /> + + + + - - - - - - - - + + + + + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/ClimateTxtD" + tools:visibility="visible"> + + + + + - - \ No newline at end of file + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 30fbc25a..f62fa29a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -891,6 +891,7 @@ Löschen Kopieren Mehr + Zieltemperatur %s Darstellung diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9f776e46..fa3d41f6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -979,6 +979,7 @@ Copy More Stop restart + Target %s Appearance