fix(ui): improve sensor unit layout and resolve dialog rendering errors
All checks were successful
Update Wiki Documentation / generate-docs (push) Successful in 2m20s
All checks were successful
Update Wiki Documentation / generate-docs (push) Successful in 2m20s
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user