mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
ui(battery): Surface health before data exists, reorder the Battery card
- 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
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-1
@@ -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<DeviceDetailItem> {
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -138,11 +138,11 @@
|
||||
<string name="settings_overview_hide_unmatched_label">Hide unmatched devices</string>
|
||||
<string name="settings_overview_hide_unmatched_description">Don\'t show nearby devices that don\'t match any of your profiles in the overview, e.g. other people\'s AirPods.</string>
|
||||
<string name="device_battery_estimate_toggle_label">Show time remaining</string>
|
||||
<string name="device_battery_estimate_card_desc">Estimate how long until this device needs charging, from its rated battery life and how fast it actually drains. Shown on the dashboard.</string>
|
||||
<string name="device_battery_estimate_card_desc">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.</string>
|
||||
<string name="device_battery_estimate_reset_action">Reset learned data</string>
|
||||
<string name="device_battery_estimate_reset_desc">Forget the measured drain and start over from the rated battery life.</string>
|
||||
<string name="device_battery_estimate_reset_desc">Forget the measured drain, charging speed and battery health, and start over from the rated battery life.</string>
|
||||
<string name="device_battery_estimate_reset_confirm_title">Reset learned data?</string>
|
||||
<string name="device_battery_estimate_reset_confirm_message">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.</string>
|
||||
<string name="device_battery_estimate_reset_confirm_message">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.</string>
|
||||
<string name="settings_acknowledgements_label">Acknowledgements</string>
|
||||
|
||||
<string name="settings_debug_autoreports_label">Automatic bug reports</string>
|
||||
@@ -480,6 +480,7 @@
|
||||
<string name="device_settings_info_battery_health_left_label">Left Battery Health (est.)</string>
|
||||
<string name="device_settings_info_battery_health_right_label">Right Battery Health (est.)</string>
|
||||
<string name="device_settings_info_battery_health_value">~%1$d%%</string>
|
||||
<string name="device_settings_info_battery_health_pending">Still determining — check back after a few more listening sessions</string>
|
||||
<string name="device_settings_info_details_label">Device Details</string>
|
||||
<string name="device_settings_info_details_action">Show device details</string>
|
||||
<string name="device_settings_info_status_label">Status</string>
|
||||
|
||||
+36
-1
@@ -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<PodDevice>(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<PodDevice>(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<PodDevice>(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
|
||||
|
||||
+27
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user