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
import android.app.Dialog
import android.content.Context
import android.graphics.Color
@@ -31,17 +32,27 @@ class LightControlDialog(
private val initialBrightness: Int,
private val onBrightnessChanged: (Int) -> Unit
) : Dialog(context) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
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
fun dp(v: Int) = (v * density + 0.5f).toInt()
val root = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(Colors.WHITE)
setPadding(dp(24), dp(24), dp(24), dp(24))
layoutParams = LinearLayout.LayoutParams(dp(320), ViewGroup.LayoutParams.WRAP_CONTENT)
}
root.addView(TextView(context).apply {
text = entityName.uppercase()
typeface = android.graphics.Typeface.MONOSPACE
@@ -49,6 +60,7 @@ class LightControlDialog(
setTextColor(Colors.BLACK)
setPadding(0, 0, 0, dp(16))
})
root.addView(View(context).apply {
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(2))
setBackgroundColor(Colors.BLACK)
@@ -80,11 +92,15 @@ class LightControlDialog(
})
}
root.addView(seekBar)
root.addView(View(context).apply {
layoutParams = LinearLayout.LayoutParams(1, dp(16))
})
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)
}
}

View File

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