From 7b403defab29741f15f4269a8e059b8f87a6d6fa Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 2 Apr 2026 12:52:39 +0200 Subject: [PATCH] fix: Add Compose stability annotations and fix lazy list keys Add @Stable to PodDevice to enable Compose referential equality checks, reducing unnecessary recompositions from the 3-second update ticker. Make icon properties non-null with built-in defaults and fix lazy list keys to use stable string identifiers. --- .../capod/main/ui/overview/OverviewScreen.kt | 4 ++-- .../capod/main/ui/overview/cards/DualPodsCard.kt | 6 +++--- .../main/ui/widget/WidgetRenderStateMapper.kt | 6 +++--- .../eu/darken/capod/monitor/core/PodDevice.kt | 15 +++++++++------ .../monitor/ui/MonitorNotificationViewFactory.kt | 12 ++++++------ .../capod/reaction/ui/popup/PopUpContent.kt | 6 +++--- 6 files changed, 26 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt index 30fc251d..f9018464 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt @@ -225,7 +225,7 @@ fun OverviewScreen( if (!state.isScanBlocked && state.isBluetoothEnabled) { items( items = state.profiledDevices, - key = { it.identifier.hashCode() }, + key = { it.identifier?.toString() ?: it.hashCode() }, ) { device -> PodDeviceCard( device = device, @@ -256,7 +256,7 @@ fun OverviewScreen( if (state.showUnmatchedDevices) { items( items = state.unmatchedDevices, - key = { "unmatched_${it.identifier.hashCode()}" }, + key = { "unmatched_${it.identifier?.toString() ?: it.hashCode()}" }, ) { device -> PodDeviceCard( device = device, diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt index 7b7cd489..e7e55960 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt @@ -157,7 +157,7 @@ fun DualPodsCard( horizontalArrangement = Arrangement.SpaceEvenly, ) { PodGauge( - iconRes = device.leftPodIcon ?: R.drawable.device_airpods_gen1_left, + iconRes = device.leftPodIcon, batteryPercent = device.batteryLeft.toBatteryFloat(), isCharging = device.isLeftPodCharging ?: false, isInEar = device.isLeftInEar ?: false, @@ -168,7 +168,7 @@ fun DualPodsCard( ) PodGauge( - iconRes = device.rightPodIcon ?: R.drawable.device_airpods_gen1_right, + iconRes = device.rightPodIcon, batteryPercent = device.batteryRight.toBatteryFloat(), isCharging = device.isRightPodCharging ?: false, isInEar = device.isRightInEar ?: false, @@ -338,7 +338,7 @@ private fun CaseRow( modifier = Modifier.fillMaxWidth(), ) { Image( - painter = painterResource(device.caseIcon ?: R.drawable.device_airpods_gen1_case), + painter = painterResource(device.caseIcon), contentDescription = null, modifier = Modifier.size(28.dp), ) diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderStateMapper.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderStateMapper.kt index f8058473..0973e2e1 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderStateMapper.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderStateMapper.kt @@ -41,15 +41,15 @@ object WidgetRenderStateMapper { resolvedIconColor = iconColor, isWide = isWide, deviceLabel = profileLabel ?: device.getLabel(context), - leftIcon = device.leftPodIcon ?: R.drawable.device_airpods_gen1_left, + leftIcon = device.leftPodIcon, leftPercent = device.batteryLeft.toBatteryFloat(), leftCharging = device.isLeftPodCharging == true, leftInEar = device.isLeftInEar == true, - rightIcon = device.rightPodIcon ?: R.drawable.device_airpods_gen1_right, + rightIcon = device.rightPodIcon, rightPercent = device.batteryRight.toBatteryFloat(), rightCharging = device.isRightPodCharging == true, rightInEar = device.isRightInEar == true, - caseIcon = device.caseIcon ?: R.drawable.device_airpods_gen1_case, + caseIcon = device.caseIcon, casePercent = device.batteryCase.toBatteryFloat(), caseCharging = device.isCaseCharging == true, ) diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt index 176b8c24..fde09bff 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt @@ -1,6 +1,8 @@ package eu.darken.capod.monitor.core import android.content.Context +import androidx.compose.runtime.Stable +import eu.darken.capod.R import eu.darken.capod.common.bluetooth.BluetoothAddress import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot @@ -24,6 +26,7 @@ import java.time.Instant * All properties are dynamically resolved from the best available source. * Consumers don't need to know whether data came from BLE or AAP. */ +@Stable data class PodDevice( internal val ble: BlePodSnapshot?, internal val aap: AapPodState?, @@ -164,14 +167,14 @@ data class PodDevice( // Icons / labels val iconRes: Int get() = ble?.iconRes ?: model.iconRes - val leftPodIcon: Int? - get() = (ble as? DualBlePodSnapshot)?.leftPodIcon + val leftPodIcon: Int + get() = (ble as? DualBlePodSnapshot)?.leftPodIcon ?: R.drawable.device_airpods_gen1_left - val rightPodIcon: Int? - get() = (ble as? DualBlePodSnapshot)?.rightPodIcon + val rightPodIcon: Int + get() = (ble as? DualBlePodSnapshot)?.rightPodIcon ?: R.drawable.device_airpods_gen1_right - val caseIcon: Int? - get() = (ble as? HasCase)?.caseIcon + val caseIcon: Int + get() = (ble as? HasCase)?.caseIcon ?: R.drawable.device_airpods_gen1_case fun getLabel(context: Context): String = ble?.getLabel(context) ?: model.label diff --git a/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotificationViewFactory.kt b/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotificationViewFactory.kt index 61fd2c98..415ce0ed 100644 --- a/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotificationViewFactory.kt +++ b/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotificationViewFactory.kt @@ -29,7 +29,7 @@ class MonitorNotificationViewFactory @Inject constructor( ).apply { // Left val leftPercent = device.batteryLeft - setImageViewResource(R.id.pod_left_icon, device.leftPodIcon ?: R.drawable.device_airpods_gen1_left) + setImageViewResource(R.id.pod_left_icon, device.leftPodIcon) setTextViewText(R.id.pod_left_label, formatBatteryPercent(context, leftPercent)) val isLeftPodCharging = device.isLeftPodCharging ?: false setViewVisibility(R.id.pod_left_charging, if (isLeftPodCharging) View.VISIBLE else View.GONE) @@ -39,7 +39,7 @@ class MonitorNotificationViewFactory @Inject constructor( // Case setViewVisibility(R.id.pod_case_charging, if (device.hasCase) View.VISIBLE else View.GONE) if (device.hasCase) { - setImageViewResource(R.id.pod_case_icon, device.caseIcon ?: R.drawable.device_airpods_gen1_case) + setImageViewResource(R.id.pod_case_icon, device.caseIcon) val casePercent = device.batteryCase setTextViewText(R.id.pod_case_label, formatBatteryPercent(context, casePercent)) setViewVisibility(R.id.pod_case_charging, if (device.isCaseCharging == true) View.VISIBLE else View.GONE) @@ -47,7 +47,7 @@ class MonitorNotificationViewFactory @Inject constructor( // Right val rightPercent = device.batteryRight - setImageViewResource(R.id.pod_right_icon, device.rightPodIcon ?: R.drawable.device_airpods_gen1_right) + setImageViewResource(R.id.pod_right_icon, device.rightPodIcon) setTextViewText(R.id.pod_right_label, formatBatteryPercent(context, rightPercent)) val isRightPodCharging = device.isRightPodCharging ?: false setViewVisibility(R.id.pod_right_charging, if (isRightPodCharging) View.VISIBLE else View.GONE) @@ -94,7 +94,7 @@ class MonitorNotificationViewFactory @Inject constructor( ).apply { // Left val leftPercent = device.batteryLeft - setImageViewResource(R.id.pod_left_icon, device.leftPodIcon ?: R.drawable.device_airpods_gen1_left) + setImageViewResource(R.id.pod_left_icon, device.leftPodIcon) setProgressBar(R.id.pod_left_progress, 100, percentToInt(leftPercent), false) setTextViewText(R.id.pod_left_label, formatBatteryPercent(context, leftPercent)) val isLeftPodCharging = device.isLeftPodCharging ?: false @@ -105,7 +105,7 @@ class MonitorNotificationViewFactory @Inject constructor( // Case setViewVisibility(R.id.pod_case_container, if (device.hasCase) View.VISIBLE else View.GONE) if (device.hasCase) { - setImageViewResource(R.id.pod_case_icon, device.caseIcon ?: R.drawable.device_airpods_gen1_case) + setImageViewResource(R.id.pod_case_icon, device.caseIcon) val casePercent = device.batteryCase setProgressBar(R.id.pod_case_progress, 100, percentToInt(casePercent), false) setTextViewText(R.id.pod_case_label, formatBatteryPercent(context, casePercent)) @@ -114,7 +114,7 @@ class MonitorNotificationViewFactory @Inject constructor( // Right val rightPercent = device.batteryRight - setImageViewResource(R.id.pod_right_icon, device.rightPodIcon ?: R.drawable.device_airpods_gen1_right) + setImageViewResource(R.id.pod_right_icon, device.rightPodIcon) setProgressBar(R.id.pod_right_progress, 100, percentToInt(rightPercent), false) setTextViewText(R.id.pod_right_label, formatBatteryPercent(context, rightPercent)) val isRightPodCharging = device.isRightPodCharging ?: false diff --git a/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt b/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt index 6cd9dafb..d6ae9605 100644 --- a/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt +++ b/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt @@ -124,7 +124,7 @@ private fun DualPodContent(device: PodDevice) { ) { // Left pod BatteryColumn( - iconRes = device.leftPodIcon ?: R.drawable.device_airpods_gen1_left, + iconRes = device.leftPodIcon, batteryPercent = device.batteryLeft.toBatteryFloat(), modifier = Modifier.weight(1f), ) @@ -132,7 +132,7 @@ private fun DualPodContent(device: PodDevice) { // Case (only if device has one) if (device.hasCase) { BatteryColumn( - iconRes = device.caseIcon ?: R.drawable.device_airpods_gen1_case, + iconRes = device.caseIcon, batteryPercent = device.batteryCase.toBatteryFloat(), modifier = Modifier.weight(1f), ) @@ -140,7 +140,7 @@ private fun DualPodContent(device: PodDevice) { // Right pod BatteryColumn( - iconRes = device.rightPodIcon ?: R.drawable.device_airpods_gen1_right, + iconRes = device.rightPodIcon, batteryPercent = device.batteryRight.toBatteryFloat(), modifier = Modifier.weight(1f), )