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.
This commit is contained in:
darken
2026-04-02 13:18:05 +02:00
committed by Matthias Urhahn
parent ceb4108306
commit 7b403defab
6 changed files with 26 additions and 23 deletions
@@ -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,
@@ -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),
)
@@ -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,
)
@@ -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
@@ -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
@@ -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),
)