diff --git a/app/src/main/java/com/example/retroha/ui/LightControlDialog.kt b/app/src/main/java/com/example/retroha/ui/LightControlDialog.kt index 9da0b7f..31c7379 100644 --- a/app/src/main/java/com/example/retroha/ui/LightControlDialog.kt +++ b/app/src/main/java/com/example/retroha/ui/LightControlDialog.kt @@ -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) } } diff --git a/app/src/main/java/com/example/retroha/ui/WidgetCardView.kt b/app/src/main/java/com/example/retroha/ui/WidgetCardView.kt index 2d4aa10..b5c04de 100644 --- a/app/src/main/java/com/example/retroha/ui/WidgetCardView.kt +++ b/app/src/main/java/com/example/retroha/ui/WidgetCardView.kt @@ -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(