From a6b37191bf9cf8422bcd1c98cff83dd992005fa1 Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 2 Jul 2026 11:39:36 +0200 Subject: [PATCH] ui(battery): Surface health before data exists, reorder the Battery card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Show a "Still determining — check back after a few more listening sessions" placeholder under Battery Health in the device info sheet while the estimate is still accumulating data, so the feature is discoverable from day one; no placeholder for profiles without a paired device, where it could never resolve - Reorder the Battery card: time remaining & health settings first, then a divider, then the charging-side settings (charge limit, charged notification) - Mention the battery-health component in the estimate toggle and reset texts - Ground the charge-band boundaries in Apple's documented fast-to-80%/trickle charging behavior and standard lithium CC/CV charging references --- .../ui/devicesettings/DeviceSettingsScreen.kt | 14 ++++--- .../devicesettings/DeviceSettingsViewModel.kt | 10 +++++ .../ui/devicesettings/cards/BatteryCard.kt | 41 +++++++++++-------- .../cards/DeviceInfoDetailItems.kt | 10 ++++- .../capod/monitor/core/battery/DrainModel.kt | 23 ++++++++--- app/src/main/res/values/strings.xml | 7 ++-- .../DeviceSettingsViewModelTest.kt | 37 ++++++++++++++++- .../cards/DeviceInfoDetailItemsTest.kt | 27 ++++++++++++ 8 files changed, 137 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt index b773a648..0179624f 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt @@ -296,18 +296,22 @@ fun DeviceSettingsScreen( info = info, labels = rememberDeviceInfoDetailLabels(), formatDate = { instant -> dateFormatter.format(instant) }, - batteryHealth = state.batteryHealth?.let { health -> - BatteryHealthTexts( - left = health.left?.let { + batteryHealth = when { + state.batteryHealth != null -> BatteryHealthTexts( + left = state.batteryHealth.left?.let { stringResource(R.string.device_settings_info_battery_health_value, it) }, - right = health.right?.let { + right = state.batteryHealth.right?.let { stringResource(R.string.device_settings_info_battery_health_value, it) }, - headset = health.headset?.let { + headset = state.batteryHealth.headset?.let { stringResource(R.string.device_settings_info_battery_health_value, it) }, ) + state.batteryHealthPending -> BatteryHealthTexts( + pending = stringResource(R.string.device_settings_info_battery_health_pending), + ) + else -> null }, ) DeviceInfoCard( diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt index 9cd442e7..b7fc559a 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt @@ -174,6 +174,13 @@ class DeviceSettingsViewModel @Inject constructor( batteryHealth = device ?.takeIf { appleProfile?.batteryEstimateEnabled ?: true } ?.let { BatteryHealth.estimate(drainProfiles[profileId], it.model) }, + // A model with a rating WILL eventually produce a health figure — surface the + // feature as "still determining" until the listening sessions accumulate. Without + // a paired device the listening gate can never open, so no promise is made. + batteryHealthPending = device != null && + (appleProfile?.batteryEstimateEnabled ?: true) && + device.model.batterySpec != null && + device.hasSelectedPairedDevice, ) } }.asLiveState() @@ -202,6 +209,9 @@ class DeviceSettingsViewModel @Inject constructor( val batteryEstimateEnabled: Boolean = true, /** Derived per-pod battery health (1..100 each), or null when there isn't enough learned data. */ val batteryHealth: BatteryHealth.PerPod? = null, + /** True when health CAN be derived for this device (rated model, feature on) — shows the + * "still determining" placeholder while [batteryHealth] is null. */ + val batteryHealthPending: Boolean = false, ) { val reactions: ReactionConfig get() = device?.reactions ?: ReactionConfig() } diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/BatteryCard.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/BatteryCard.kt index 49ab4c95..1e40e657 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/BatteryCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/BatteryCard.kt @@ -20,6 +20,7 @@ import eu.darken.capod.R import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.settings.SettingsBaseItem +import eu.darken.capod.common.settings.SettingsDivider import eu.darken.capod.common.settings.SettingsSection import eu.darken.capod.common.settings.SettingsSliderItem import eu.darken.capod.common.settings.SettingsSwitchItem @@ -31,14 +32,15 @@ import eu.darken.capod.profiles.core.ReactionConfig import eu.darken.capod.reaction.core.charged.ChargedSlotScope /** - * The device's "Battery" settings, grouping everything charge/battery-related: + * The device's "Battery" settings, grouping everything charge/battery-related. Time remaining & + * battery health lead, then a divider, then the charging-side settings: + * - The time-remaining/health estimate: a per-device toggle (disabling pauses/hides without + * discarding learned data) and a reset that wipes the learned drain, charge and health data so + * it starts over from the model's rated battery life. * - Apple's "Optimized Charge Limit" (AAP setting 0x3B) — only for models that advertise * [PodModel.Features.hasDynamicEndOfCharge] over an active AAP session. * - "Notify when charged" (a per-device reaction) — fires purely off observed charging state, so it * works for any live device (BLE or AAP), not just when the phone is the audio source. - * - The dashboard time-remaining estimate: a per-device toggle (disabling pauses/hides without - * discarding learned data) and a reset that wipes the learned drain so it starts over from the - * model's rated battery life. * * Each row is gated independently; the whole card hides when nothing applies. */ @@ -66,6 +68,24 @@ internal fun BatteryCard( var showChargedScopeDialog by remember { mutableStateOf(false) } SettingsSection(title = stringResource(R.string.device_settings_category_battery_label)) { + // Time remaining & battery health lead — the headline feature. Charging-side settings + // (Apple's charge limit, the charged notification) follow below the divider. + if (showLiveControls) { + SettingsSwitchItem( + icon = Icons.TwoTone.Schedule, + title = stringResource(R.string.device_battery_estimate_toggle_label), + subtitle = stringResource(R.string.device_battery_estimate_card_desc), + checked = estimateEnabled, + onCheckedChange = onEstimateEnabledChange, + ) + SettingsBaseItem( + icon = Icons.TwoTone.RestartAlt, + title = stringResource(R.string.device_battery_estimate_reset_action), + subtitle = stringResource(R.string.device_battery_estimate_reset_desc), + onClick = { showResetConfirm = true }, + ) + SettingsDivider() + } if (chargeCap != null) { SettingsSwitchItem( icon = Icons.TwoTone.BatteryChargingFull, @@ -110,19 +130,6 @@ internal fun BatteryCard( ) } } - SettingsSwitchItem( - icon = Icons.TwoTone.Schedule, - title = stringResource(R.string.device_battery_estimate_toggle_label), - subtitle = stringResource(R.string.device_battery_estimate_card_desc), - checked = estimateEnabled, - onCheckedChange = onEstimateEnabledChange, - ) - SettingsBaseItem( - icon = Icons.TwoTone.RestartAlt, - title = stringResource(R.string.device_battery_estimate_reset_action), - subtitle = stringResource(R.string.device_battery_estimate_reset_desc), - onClick = { showResetConfirm = true }, - ) } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItems.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItems.kt index fd406431..69a597fb 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItems.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItems.kt @@ -39,11 +39,16 @@ internal data class DeviceInfoDetailLabels( val rightBatteryHealth: String, ) -/** Pre-formatted per-pod health values ("~85%") for the info sheet; null slots are omitted. */ +/** + * Pre-formatted per-pod health values ("~85%") for the info sheet; null slots are omitted. + * [pending] is a placeholder shown under the health label while NO value exists yet — the feature + * stays discoverable while the estimator is still accumulating listening sessions. + */ internal data class BatteryHealthTexts( val left: String? = null, val right: String? = null, val headset: String? = null, + val pending: String? = null, ) private fun healthItems(health: BatteryHealthTexts?, labels: DeviceInfoDetailLabels): List { @@ -60,6 +65,9 @@ private fun healthItems(health: BatteryHealthTexts?, labels: DeviceInfoDetailLab health.right != null -> add(DeviceDetailItem.Single(labels.rightBatteryHealth, health.right)) } health.headset?.let { add(DeviceDetailItem.Single(labels.batteryHealth, it)) } + if (isEmpty() && health.pending != null) { + add(DeviceDetailItem.Single(labels.batteryHealth, health.pending)) + } } } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/battery/DrainModel.kt b/app/src/main/java/eu/darken/capod/monitor/core/battery/DrainModel.kt index 0fffb7ef..23950747 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/battery/DrainModel.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/battery/DrainModel.kt @@ -118,14 +118,27 @@ object DrainModel { } /** - * The three regimes of a lithium charge. Constant-current bulk is fast and roughly linear; - * above ~80% the charger switches to constant-voltage and the intake tapers, ending in a slow - * trickle. One linear rate over-promises badly above 80%, so each band learns its own rate. + * The regimes of a lithium CC/CV charge. Constant-current bulk is fast and roughly linear; + * once the cell hits its voltage ceiling the charger switches to constant-voltage and the + * current decays, ending in a slow trickle. One linear rate over-promises badly above ~80%, + * so each band learns its own rate. + * + * Band boundaries and seeds are grounded rather than guessed: + * - The 80% bulk boundary is Apple's own documented behavior for their devices ("uses fast + * charging to quickly reach 80% ... then switches to slower trickle charging", + * apple.com/batteries/why-lithium-ion, mirrored in the iPhone user guide). + * - Battery-charging literature places the CC->CV transition at ~75-85% SoC (lower at high + * charge currents) and describes the CV current as decaying until cutoff, with the + * saturation stage taking a large share of total charge time (Battery University BU-409, + * "Charging Lithium-ion"). + * - Two CV bands, not more: the CV current decays continuously, so finer bands would model + * noise while multiplying the state to learn. With the 0.5/0.3 seeds below, the last 20% + * takes ~40% of the seeded total charge time — inside BU-409's reported envelope. * * [specMultiplier] scales the spec-derived seed for the band: Apple's quick-charge claims * ("5 minutes = ~1 hour of listening") measure the bulk phase, so seeding the taper/trickle - * bands from them needs a haircut. Applied ONLY to the spec seed — measured or learned rates - * already reflect where they were observed. + * bands from them needs the CV haircut. Applied ONLY to the spec seed — measured or learned + * rates already reflect where they were observed. */ enum class ChargeBand(val from: Float, val to: Float, val specMultiplier: Float) { BULK(0.0f, 0.8f, 1.0f), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 737ae9c7..22c11b65 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -138,11 +138,11 @@ Hide unmatched devices Don\'t show nearby devices that don\'t match any of your profiles in the overview, e.g. other people\'s AirPods. Show time remaining - Estimate how long until this device needs charging, from its rated battery life and how fast it actually drains. Shown on the dashboard. + Estimate how long until this device needs charging and how healthy its batteries are, from the rated battery life and how it actually charges and drains. Shown on the dashboard and in the device info. Reset learned data - Forget the measured drain and start over from the rated battery life. + Forget the measured drain, charging speed and battery health, and start over from the rated battery life. Reset learned data? - CAPod will forget what it learned about this device\'s battery drain and estimate from the rated battery life again, re-learning as you use it. + CAPod will forget what it learned about this device\'s battery drain, charging speed and battery health, and estimate from the rated battery life again, re-learning as you use it. Acknowledgements Automatic bug reports @@ -480,6 +480,7 @@ Left Battery Health (est.) Right Battery Health (est.) ~%1$d%% + Still determining — check back after a few more listening sessions Device Details Show device details Status diff --git a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt index b75f3eeb..11a85fd0 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt @@ -575,6 +575,39 @@ class DeviceSettingsViewModelTest : BaseTest() { vm.state.first().batteryHealth shouldBe BatteryHealth.PerPod(left = 50) } + @Test + fun `battery health pending flag is set for rated models without data`() = runVmTest { + val device = mockk(relaxed = true).also { + every { it.profileId } returns testAddress + every { it.model } returns PodModel.AIRPODS_PRO2 + every { it.hasSelectedPairedDevice } returns true + } + devicesFlow.value = listOf(device) + + val vm = createViewModel() + vm.initialize(testAddress) + + val state = vm.state.first() + state.batteryHealth shouldBe null + state.batteryHealthPending shouldBe true + } + + @Test + fun `battery health pending flag stays off without a paired device`() = runVmTest { + // No paired address -> the listening gate can never open -> "check back later" would lie. + val device = mockk(relaxed = true).also { + every { it.profileId } returns testAddress + every { it.model } returns PodModel.AIRPODS_PRO2 + every { it.hasSelectedPairedDevice } returns false + } + devicesFlow.value = listOf(device) + + val vm = createViewModel() + vm.initialize(testAddress) + + vm.state.first().batteryHealthPending shouldBe false + } + @Test fun `battery health hides when the estimate is disabled for the device`() = runVmTest { val device = mockk(relaxed = true).also { @@ -607,7 +640,9 @@ class DeviceSettingsViewModelTest : BaseTest() { val vm = createViewModel() vm.initialize(testAddress) - vm.state.first().batteryHealth shouldBe null + val state = vm.state.first() + state.batteryHealth shouldBe null + state.batteryHealthPending shouldBe false } @Test diff --git a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItemsTest.kt b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItemsTest.kt index 3752c602..bc86b9ca 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItemsTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoDetailItemsTest.kt @@ -107,6 +107,33 @@ class DeviceInfoDetailItemsTest : BaseTest() { ) } + @Test + fun `pending placeholder shows while no health value exists`() { + // The feature stays discoverable before enough listening data has accumulated. + val result = buildDeviceInfoDetailItems( + null, + labels, + batteryHealth = BatteryHealthTexts(pending = "Still determining"), + formatDate = formatter, + ) + result shouldContainExactly listOf( + DeviceDetailItem.Single("Battery Health", "Still determining"), + ) + } + + @Test + fun `pending placeholder is ignored once a health value exists`() { + val result = buildDeviceInfoDetailItems( + null, + labels, + batteryHealth = BatteryHealthTexts(left = "~85%", pending = "Still determining"), + formatDate = formatter, + ) + result shouldContainExactly listOf( + DeviceDetailItem.Single("Left Battery Health", "~85%"), + ) + } + @Test fun `headset battery health yields a Single row with the generic label`() { val result = buildDeviceInfoDetailItems(