fix(ui): improve sensor unit layout and resolve dialog rendering errors
All checks were successful
Update Wiki Documentation / generate-docs (push) Successful in 2m20s

This commit is contained in:
Krzysztof Cieślik
2026-06-14 15:54:05 +02:00
parent e8db4da537
commit ff8b94feea
2 changed files with 86 additions and 15 deletions

View File

@@ -1,4 +1,5 @@
package com.example.retroha.ui package com.example.retroha.ui
import android.app.Dialog import android.app.Dialog
import android.content.Context import android.content.Context
import android.graphics.Color import android.graphics.Color
@@ -31,17 +32,27 @@ class LightControlDialog(
private val initialBrightness: Int, private val initialBrightness: Int,
private val onBrightnessChanged: (Int) -> Unit private val onBrightnessChanged: (Int) -> Unit
) : Dialog(context) { ) : Dialog(context) {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
requestWindowFeature(Window.FEATURE_NO_TITLE) requestWindowFeature(Window.FEATURE_NO_TITLE)
// Disable hardware acceleration for this dialog window to avoid EGL errors on old hardware
window?.setFlags(
android.view.WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
android.view.WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED
)
val density = context.resources.displayMetrics.density val density = context.resources.displayMetrics.density
fun dp(v: Int) = (v * density + 0.5f).toInt() fun dp(v: Int) = (v * density + 0.5f).toInt()
val root = LinearLayout(context).apply { val root = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
setBackgroundColor(Colors.WHITE) setBackgroundColor(Colors.WHITE)
setPadding(dp(24), dp(24), dp(24), dp(24)) setPadding(dp(24), dp(24), dp(24), dp(24))
layoutParams = LinearLayout.LayoutParams(dp(320), ViewGroup.LayoutParams.WRAP_CONTENT) layoutParams = LinearLayout.LayoutParams(dp(320), ViewGroup.LayoutParams.WRAP_CONTENT)
} }
root.addView(TextView(context).apply { root.addView(TextView(context).apply {
text = entityName.uppercase() text = entityName.uppercase()
typeface = android.graphics.Typeface.MONOSPACE typeface = android.graphics.Typeface.MONOSPACE
@@ -49,6 +60,7 @@ class LightControlDialog(
setTextColor(Colors.BLACK) setTextColor(Colors.BLACK)
setPadding(0, 0, 0, dp(16)) setPadding(0, 0, 0, dp(16))
}) })
root.addView(View(context).apply { root.addView(View(context).apply {
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(2)) layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(2))
setBackgroundColor(Colors.BLACK) setBackgroundColor(Colors.BLACK)
@@ -80,11 +92,15 @@ class LightControlDialog(
}) })
} }
root.addView(seekBar) root.addView(seekBar)
root.addView(View(context).apply { root.addView(View(context).apply {
layoutParams = LinearLayout.LayoutParams(1, dp(16)) layoutParams = LinearLayout.LayoutParams(1, dp(16))
}) })
setContentView(root) setContentView(root)
window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
// Use a simple solid background for the window to avoid match errors on old drivers
window?.setBackgroundDrawable(ColorDrawable(Colors.WHITE))
setCanceledOnTouchOutside(true) setCanceledOnTouchOutside(true)
} }
} }

View File

@@ -1,4 +1,5 @@
package com.example.retroha.ui package com.example.retroha.ui
import android.animation.ObjectAnimator import android.animation.ObjectAnimator
import android.content.Context import android.content.Context
import android.graphics.Canvas import android.graphics.Canvas
@@ -17,6 +18,7 @@ import com.example.retroha.model.WidgetConfig
import com.example.retroha.model.WidgetInteraction import com.example.retroha.model.WidgetInteraction
import com.example.retroha.model.toWidgetInteraction import com.example.retroha.model.toWidgetInteraction
import com.example.retroha.theme.Colors import com.example.retroha.theme.Colors
/** /**
* A highly optimized custom [View] that renders a single Home Assistant entity card. * A highly optimized custom [View] that renders a single Home Assistant entity card.
* Part of the "Bauhaus Canvas" engine, it avoids XML overhead by manually drawing everything. * Part of the "Bauhaus Canvas" engine, it avoids XML overhead by manually drawing everything.
@@ -51,16 +53,19 @@ class WidgetCardView(context: Context) : View(context) {
var onToggle: ((WidgetConfig) -> Unit)? = null var onToggle: ((WidgetConfig) -> Unit)? = null
/** Callback for long clicks (more info / brightness dialog). */ /** Callback for long clicks (more info / brightness dialog). */
var onLongToggle: ((WidgetConfig) -> Unit)? = null var onLongToggle: ((WidgetConfig) -> Unit)? = null
private var config: WidgetConfig? = null private var config: WidgetConfig? = null
private var currentInteraction: WidgetInteraction = WidgetInteraction.READ_ONLY private var currentInteraction: WidgetInteraction = WidgetInteraction.READ_ONLY
private var lLabel: StaticLayout? = null private var lLabel: StaticLayout? = null
private var lValue: StaticLayout? = null private var lValue: StaticLayout? = null
private var lSecondary: StaticLayout? = null private var lSecondary: StaticLayout? = null
private var pulseAnim: ObjectAnimator? = null private var pulseAnim: ObjectAnimator? = null
private var cachedLabel = "" private var cachedLabel = ""
private var cachedValue = "" private var cachedValue = ""
private var cachedSecondary = "" private var cachedSecondary = ""
private var cachedTextW = 0 private var cachedTextW = 0
init { init {
isFocusable = true isFocusable = true
setOnClickListener { setOnClickListener {
@@ -73,15 +78,18 @@ class WidgetCardView(context: Context) : View(context) {
true true
} }
} }
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val w = MeasureSpec.getSize(widthMeasureSpec) val w = MeasureSpec.getSize(widthMeasureSpec)
setMeasuredDimension(w, cardHeightPx + shadowPx) setMeasuredDimension(w, cardHeightPx + shadowPx)
} }
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh) super.onSizeChanged(w, h, oldw, oldh)
updateExecIconPath(w - shadowPx) updateExecIconPath(w - shadowPx)
rebuildLayoutsIfNeeded(w) rebuildLayoutsIfNeeded(w)
} }
/** /**
* Binds new data to the widget and updates the visual state. * Binds new data to the widget and updates the visual state.
* Triggers layout recalculation if text content or view width has changed. * Triggers layout recalculation if text content or view width has changed.
@@ -93,12 +101,17 @@ class WidgetCardView(context: Context) : View(context) {
currentInteraction = cfg.domain.toWidgetInteraction() currentInteraction = cfg.domain.toWidgetInteraction()
isClickable = currentInteraction != WidgetInteraction.READ_ONLY isClickable = currentInteraction != WidgetInteraction.READ_ONLY
&& cfg.state != EntityState.UNAVAILABLE && cfg.state != EntityState.UNAVAILABLE
&& cfg.state != EntityState.TOGGLING
tpValue.color = if (cfg.state == EntityState.UNAVAILABLE) Colors.GRAY_MID else Colors.BLACK if (cfg.state == EntityState.TOGGLING) {
if (cfg.state == EntityState.TOGGLING) startPulse() else stopPulse() startPulse()
if (width > 0) rebuildLayoutsIfNeeded(width) } else {
stopPulse()
}
rebuildLayoutsIfNeeded(width)
invalidate() invalidate()
} }
private fun updateExecIconPath(cardW: Int) { private fun updateExecIconPath(cardW: Int) {
val sz = execIconSize val sz = execIconSize
val right = cardW.toFloat() - dp(8) val right = cardW.toFloat() - dp(8)
@@ -109,37 +122,59 @@ class WidgetCardView(context: Context) : View(context) {
execIconPath.lineTo(right - sz, top + sz) execIconPath.lineTo(right - sz, top + sz)
execIconPath.close() execIconPath.close()
} }
private fun rebuildLayoutsIfNeeded(viewW: Int) { private fun rebuildLayoutsIfNeeded(viewW: Int) {
val cfg = config ?: return val cfg = config ?: return
val cardW = viewW - shadowPx val cardW = viewW - shadowPx
val textW = (cardW - textLeft - textRight).coerceAtLeast(1) val textW = (cardW - textLeft - textRight).coerceAtLeast(1)
val labelChanged = cfg.label != cachedLabel val labelChanged = cfg.label != cachedLabel
val textChanged = labelChanged || cfg.value != cachedValue || cfg.secondary != cachedSecondary val textChanged = labelChanged || cfg.value != cachedValue || cfg.secondary != cachedSecondary
val widthChanged = textW != cachedTextW val widthChanged = textW != cachedTextW
if (!textChanged && !widthChanged && lLabel != null) return if (!textChanged && !widthChanged && lLabel != null) return
val labelUp = if (labelChanged) cfg.label.uppercase() else cachedLabel val labelUp = if (labelChanged) cfg.label.uppercase() else cachedLabel
// For sensors/numbers, append unit to value to prevent line wrapping
val valueText = if (cfg.secondary.length <= 3 && cfg.secondary.isNotEmpty()) {
"${cfg.value} ${cfg.secondary}"
} else {
cfg.value
}
val secondaryText = if (cfg.secondary.length <= 3) "" else cfg.secondary
lLabel = makeLayout(labelUp, tpLabel, textW) lLabel = makeLayout(labelUp, tpLabel, textW)
lValue = makeLayout(cfg.value, tpValue, textW) lValue = makeLayout(valueText, tpValue, textW)
lSecondary = makeLayout(cfg.secondary, tpSecondary, textW) lSecondary = makeLayout(secondaryText, tpSecondary, textW)
cachedLabel = cfg.label cachedLabel = cfg.label
cachedValue = cfg.value cachedValue = cfg.value
cachedSecondary = cfg.secondary cachedSecondary = cfg.secondary
cachedTextW = textW cachedTextW = textW
} }
override fun onDraw(canvas: Canvas) { override fun onDraw(canvas: Canvas) {
val cfg = config ?: return val cfg = config ?: return
val cardW = (width - shadowPx).toFloat() val cardW = (width - shadowPx).toFloat()
val cardH = cardHeightPx.toFloat() val cardH = cardHeightPx.toFloat()
val b = borderPx.toFloat() val b = borderPx.toFloat()
val s = shadowPx.toFloat() val s = shadowPx.toFloat()
// Shadow
paintFill.color = Colors.BLACK paintFill.color = Colors.BLACK
canvas.drawRect(s, s, width.toFloat(), cardH + s, paintFill) canvas.drawRect(s, s, width.toFloat(), cardH + s, paintFill)
// Border
paintFill.color = when (cfg.state) { paintFill.color = when (cfg.state) {
EntityState.TOGGLING -> Colors.BORDER_TOGGLING EntityState.TOGGLING -> Colors.BORDER_TOGGLING
EntityState.UNAVAILABLE -> Colors.BORDER_UNAVAILABLE EntityState.UNAVAILABLE -> Colors.BORDER_UNAVAILABLE
else -> Colors.BORDER_DEFAULT else -> Colors.BORDER_DEFAULT
} }
canvas.drawRect(0f, 0f, cardW, cardH, paintFill) canvas.drawRect(0f, 0f, cardW, cardH, paintFill)
// Background
paintFill.color = when (cfg.state) { paintFill.color = when (cfg.state) {
EntityState.ON -> Colors.STATUS_ON EntityState.ON -> Colors.STATUS_ON
EntityState.OFF -> Colors.STATUS_OFF EntityState.OFF -> Colors.STATUS_OFF
@@ -147,16 +182,24 @@ class WidgetCardView(context: Context) : View(context) {
EntityState.TOGGLING -> Colors.STATUS_TOGGLING EntityState.TOGGLING -> Colors.STATUS_TOGGLING
} }
canvas.drawRect(b, b, cardW - b, cardH - b, paintFill) canvas.drawRect(b, b, cardW - b, cardH - b, paintFill)
// Domain Stripe
paintFill.color = stripeColor(cfg.domain) paintFill.color = stripeColor(cfg.domain)
canvas.drawRect(b, b, b + stripePx, cardH - b, paintFill) canvas.drawRect(b, b, b + stripePx, cardH - b, paintFill)
// Icon
val iconSize = dp(16).toFloat() val iconSize = dp(16).toFloat()
val iconX = cardW - iconSize - dp(8) val iconX = cardW - iconSize - dp(8)
val iconY = dp(8).toFloat() val iconY = dp(8).toFloat()
HaIcons.draw(canvas, cfg.domain, iconX, iconY, iconSize, paintFill.color) HaIcons.draw(canvas, cfg.domain, iconX, iconY, iconSize, paintFill.color)
// Execute Icon (triangle)
if (currentInteraction == WidgetInteraction.EXECUTE && cfg.state != EntityState.UNAVAILABLE) { if (currentInteraction == WidgetInteraction.EXECUTE && cfg.state != EntityState.UNAVAILABLE) {
paintFill.color = Colors.GRAY_MID paintFill.color = Colors.GRAY_MID
canvas.drawPath(execIconPath, paintFill) canvas.drawPath(execIconPath, paintFill)
} }
// Text Layouts
val tl = lLabel; val tv = lValue; val ts = lSecondary val tl = lLabel; val tv = lValue; val ts = lSecondary
if (tl != null && tv != null && ts != null) { if (tl != null && tv != null && ts != null) {
canvas.save() canvas.save()
@@ -164,19 +207,25 @@ class WidgetCardView(context: Context) : View(context) {
tl.draw(canvas) tl.draw(canvas)
canvas.translate(0f, (tl.height + lineGap1).toFloat()) canvas.translate(0f, (tl.height + lineGap1).toFloat())
tv.draw(canvas) tv.draw(canvas)
canvas.translate(0f, (tv.height + lineGap2).toFloat()) if (ts.text.isNotEmpty()) {
ts.draw(canvas) canvas.translate(0f, (tv.height + lineGap2).toFloat())
ts.draw(canvas)
}
canvas.restore() canvas.restore()
} }
// Press overlay
if (isPressed) { if (isPressed) {
paintFill.color = 0x22000000 paintFill.color = 0x22000000
canvas.drawRect(0f, 0f, cardW, cardH, paintFill) canvas.drawRect(0f, 0f, cardW, cardH, paintFill)
} }
} }
override fun drawableStateChanged() { override fun drawableStateChanged() {
super.drawableStateChanged() super.drawableStateChanged()
invalidate() invalidate()
} }
private fun startPulse() { private fun startPulse() {
if (pulseAnim?.isRunning == true) return if (pulseAnim?.isRunning == true) return
setLayerType(LAYER_TYPE_HARDWARE, null) setLayerType(LAYER_TYPE_HARDWARE, null)
@@ -188,12 +237,14 @@ class WidgetCardView(context: Context) : View(context) {
start() start()
} }
} }
private fun stopPulse() { private fun stopPulse() {
pulseAnim?.cancel() pulseAnim?.cancel()
pulseAnim = null pulseAnim = null
setLayerType(LAYER_TYPE_NONE, null)
alpha = 1f alpha = 1f
setLayerType(LAYER_TYPE_NONE, null)
} }
private fun stripeColor(domain: String): Int = when (domain) { private fun stripeColor(domain: String): Int = when (domain) {
"light" -> Colors.STRIPE_LIGHT "light" -> Colors.STRIPE_LIGHT
"switch" -> Colors.STRIPE_SWITCH "switch" -> Colors.STRIPE_SWITCH
@@ -203,15 +254,19 @@ class WidgetCardView(context: Context) : View(context) {
"automation" -> Colors.STRIPE_AUTOMATION "automation" -> Colors.STRIPE_AUTOMATION
else -> Colors.STRIPE_DEFAULT else -> Colors.STRIPE_DEFAULT
} }
private fun dp(v: Int) = (v * density + 0.5f).toInt() private fun dp(v: Int) = (v * density + 0.5f).toInt()
private fun sp(v: Int) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, v.toFloat(), resources.displayMetrics) private fun sp(v: Int) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, v.toFloat(), resources.displayMetrics)
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
private fun makeLayout(text: String, paint: TextPaint, width: Int): StaticLayout = private fun makeLayout(text: CharSequence, paint: TextPaint, width: Int): StaticLayout =
if (Build.VERSION.SDK_INT >= 23) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
StaticLayout.Builder StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
.obtain(text, 0, text.length, paint, width) .setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setMaxLines(1) .setLineSpacing(0f, 1f)
.setIncludePad(false)
.setEllipsize(TextUtils.TruncateAt.END) .setEllipsize(TextUtils.TruncateAt.END)
.setMaxLines(1)
.build() .build()
else else
StaticLayout( StaticLayout(