mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-16 11:16:12 -04:00
ui(battery): Show charge ETA in the charging chip, make health per-pod
- The gauge line under the percentage now always shows the runtime estimate
("if used now") even while charging; the time-until-charged moved into the
charging chip itself ("Charging · 25m"), so the two can't be confused
- Battery health is now computed and shown per pod (Left/Right paired row in
the info sheet, mirroring the serial rows) — single-pod listening habits or
a replaced earbud make the sides genuinely diverge, and a combined figure
would mask a failing pod
This commit is contained in:
@@ -43,6 +43,7 @@ import eu.darken.capod.common.settings.SettingsInfoBox
|
||||
import eu.darken.capod.common.settings.SettingsSection
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.BatteryCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.BatteryHealthTexts
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.ControlsCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard
|
||||
@@ -295,8 +296,18 @@ fun DeviceSettingsScreen(
|
||||
info = info,
|
||||
labels = rememberDeviceInfoDetailLabels(),
|
||||
formatDate = { instant -> dateFormatter.format(instant) },
|
||||
batteryHealth = state.batteryHealthPercent?.let {
|
||||
stringResource(R.string.device_settings_info_battery_health_value, it)
|
||||
batteryHealth = state.batteryHealth?.let { health ->
|
||||
BatteryHealthTexts(
|
||||
left = health.left?.let {
|
||||
stringResource(R.string.device_settings_info_battery_health_value, it)
|
||||
},
|
||||
right = health.right?.let {
|
||||
stringResource(R.string.device_settings_info_battery_health_value, it)
|
||||
},
|
||||
headset = health.headset?.let {
|
||||
stringResource(R.string.device_settings_info_battery_health_value, it)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
DeviceInfoCard(
|
||||
|
||||
@@ -171,9 +171,9 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
batteryEstimateEnabled = appleProfile?.batteryEstimateEnabled ?: true,
|
||||
// Health rides on the same learned data as the estimate — the per-device toggle
|
||||
// governs both.
|
||||
batteryHealthPercent = device
|
||||
batteryHealth = device
|
||||
?.takeIf { appleProfile?.batteryEstimateEnabled ?: true }
|
||||
?.let { BatteryHealth.estimatePercent(drainProfiles[profileId], it.model) },
|
||||
?.let { BatteryHealth.estimate(drainProfiles[profileId], it.model) },
|
||||
)
|
||||
}
|
||||
}.asLiveState()
|
||||
@@ -200,8 +200,8 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
val systemBluetoothName: String? = null,
|
||||
val hasCustomLongPressStemAction: Boolean = false,
|
||||
val batteryEstimateEnabled: Boolean = true,
|
||||
/** Derived battery health (1..100), or null when there isn't enough learned data. */
|
||||
val batteryHealthPercent: Int? = null,
|
||||
/** Derived per-pod battery health (1..100 each), or null when there isn't enough learned data. */
|
||||
val batteryHealth: BatteryHealth.PerPod? = null,
|
||||
) {
|
||||
val reactions: ReactionConfig get() = device?.reactions ?: ReactionConfig()
|
||||
}
|
||||
|
||||
+31
-5
@@ -19,6 +19,8 @@ internal fun rememberDeviceInfoDetailLabels() = DeviceInfoDetailLabels(
|
||||
leftBonded = stringResource(R.string.device_settings_info_left_bonded_label),
|
||||
rightBonded = stringResource(R.string.device_settings_info_right_bonded_label),
|
||||
batteryHealth = stringResource(R.string.device_settings_info_battery_health_label),
|
||||
leftBatteryHealth = stringResource(R.string.device_settings_info_battery_health_left_label),
|
||||
rightBatteryHealth = stringResource(R.string.device_settings_info_battery_health_right_label),
|
||||
)
|
||||
|
||||
internal data class DeviceInfoDetailLabels(
|
||||
@@ -33,19 +35,43 @@ internal data class DeviceInfoDetailLabels(
|
||||
val leftBonded: String,
|
||||
val rightBonded: String,
|
||||
val batteryHealth: String,
|
||||
val leftBatteryHealth: String,
|
||||
val rightBatteryHealth: String,
|
||||
)
|
||||
|
||||
/** Pre-formatted per-pod health values ("~85%") for the info sheet; null slots are omitted. */
|
||||
internal data class BatteryHealthTexts(
|
||||
val left: String? = null,
|
||||
val right: String? = null,
|
||||
val headset: String? = null,
|
||||
)
|
||||
|
||||
private fun healthItems(health: BatteryHealthTexts?, labels: DeviceInfoDetailLabels): List<DeviceDetailItem> {
|
||||
if (health == null) return emptyList()
|
||||
return buildList {
|
||||
when {
|
||||
health.left != null && health.right != null -> add(
|
||||
DeviceDetailItem.Paired(
|
||||
start = DeviceDetailItem.Single(labels.leftBatteryHealth, health.left),
|
||||
end = DeviceDetailItem.Single(labels.rightBatteryHealth, health.right),
|
||||
)
|
||||
)
|
||||
health.left != null -> add(DeviceDetailItem.Single(labels.leftBatteryHealth, health.left))
|
||||
health.right != null -> add(DeviceDetailItem.Single(labels.rightBatteryHealth, health.right))
|
||||
}
|
||||
health.headset?.let { add(DeviceDetailItem.Single(labels.batteryHealth, it)) }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun buildDeviceInfoDetailItems(
|
||||
info: AapDeviceInfo?,
|
||||
labels: DeviceInfoDetailLabels,
|
||||
batteryHealth: String? = null,
|
||||
batteryHealth: BatteryHealthTexts? = null,
|
||||
formatDate: (Instant) -> String,
|
||||
): List<DeviceDetailItem> {
|
||||
// Battery health is derived locally, so it's available (and shows the info button) even for
|
||||
// BLE-only devices that never produce an AAP device-info response.
|
||||
if (info == null) {
|
||||
return batteryHealth?.let { listOf(DeviceDetailItem.Single(labels.batteryHealth, it)) } ?: emptyList()
|
||||
}
|
||||
if (info == null) return healthItems(batteryHealth, labels)
|
||||
return buildList {
|
||||
info.manufacturer.takeIf { it.isNotBlank() }?.let {
|
||||
add(DeviceDetailItem.Single(labels.manufacturer, it))
|
||||
@@ -89,6 +115,6 @@ internal fun buildDeviceInfoDetailItems(
|
||||
leftBonded != null -> add(DeviceDetailItem.Single(labels.leftBonded, leftBonded))
|
||||
rightBonded != null -> add(DeviceDetailItem.Single(labels.rightBonded, rightBonded))
|
||||
}
|
||||
batteryHealth?.let { add(DeviceDetailItem.Single(labels.batteryHealth, it)) }
|
||||
addAll(healthItems(batteryHealth, labels))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package eu.darken.capod.main.ui.overview.cards
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
@@ -240,9 +239,8 @@ private fun ColumnScope.DualPodsCardExpanded(
|
||||
isMicrophone = device.isLeftPodMicrophone ?: false,
|
||||
showMicrophone = device.hasDualMicrophone,
|
||||
modifier = Modifier.weight(1f),
|
||||
timeRemaining = batteryEstimate?.left?.let {
|
||||
formatEstimateText(context, it, isCharging = device.isLeftPodCharging == true)
|
||||
},
|
||||
timeRemaining = batteryEstimate?.left?.let { formatBatteryDurationShort(context, it.minutesRemaining) },
|
||||
untilCharged = batteryEstimate?.left?.minutesUntilCharged?.let { formatBatteryDurationShort(context, it) },
|
||||
)
|
||||
|
||||
PodGauge(
|
||||
@@ -255,9 +253,8 @@ private fun ColumnScope.DualPodsCardExpanded(
|
||||
isMicrophone = device.isRightPodMicrophone ?: false,
|
||||
showMicrophone = device.hasDualMicrophone,
|
||||
modifier = Modifier.weight(1f),
|
||||
timeRemaining = batteryEstimate?.right?.let {
|
||||
formatEstimateText(context, it, isCharging = device.isRightPodCharging == true)
|
||||
},
|
||||
timeRemaining = batteryEstimate?.right?.let { formatBatteryDurationShort(context, it.minutesRemaining) },
|
||||
untilCharged = batteryEstimate?.right?.minutesUntilCharged?.let { formatBatteryDurationShort(context, it) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -313,6 +310,7 @@ private fun PodGauge(
|
||||
showMicrophone: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
timeRemaining: String? = null,
|
||||
untilCharged: String? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val clamped = if (batteryPercent >= 0f) batteryPercent.coerceIn(0f, 1f) else -1f
|
||||
@@ -405,23 +403,11 @@ private fun PodGauge(
|
||||
chargingOptimizedLabel = stringResource(R.string.pods_charging_optimized_label),
|
||||
inEarLabel = stringResource(R.string.pods_inear_label),
|
||||
microphoneLabel = stringResource(R.string.pods_microphone_label),
|
||||
chargingDetail = untilCharged,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The gauge's small estimate line: while charging, the time until full (language-neutral "⚡ 25m")
|
||||
* or NOTHING — a bare runtime number next to a charging chip ("1% · 4m") inevitably reads as a
|
||||
* four-minute charge. The runtime estimate only shows while not charging. Shared with
|
||||
* [SinglePodsCard] (same package).
|
||||
*/
|
||||
internal fun formatEstimateText(context: Context, pod: BatteryEstimate.Pod, isCharging: Boolean): String? = when {
|
||||
pod.minutesUntilCharged != null ->
|
||||
context.getString(R.string.battery_time_until_charged_short, formatBatteryDurationShort(context, pod.minutesUntilCharged))
|
||||
isCharging -> null
|
||||
else -> formatBatteryDurationShort(context, pod.minutesRemaining)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun CaseRow(
|
||||
|
||||
@@ -267,12 +267,10 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
val headsetEstimate = batteryEstimate?.headset?.let {
|
||||
formatEstimateText(context, it, isCharging = device.isHeadsetBeingCharged == true)
|
||||
}
|
||||
val headsetEstimate = batteryEstimate?.headset
|
||||
if (headsetEstimate != null) {
|
||||
Text(
|
||||
text = headsetEstimate,
|
||||
text = formatBatteryDurationShort(context, headsetEstimate.minutesRemaining),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
@@ -296,10 +294,18 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = stringResource(R.string.pods_charging_optimized_label),
|
||||
)
|
||||
AapPodState.ChargingState.CHARGING -> StatusChip(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = stringResource(R.string.pods_charging_label),
|
||||
)
|
||||
AapPodState.ChargingState.CHARGING -> {
|
||||
// Time-until-charged lives in the charging chip; the in-ring estimate
|
||||
// always means runtime, so the two can't be confused.
|
||||
val untilCharged = batteryEstimate?.headset?.minutesUntilCharged
|
||||
?.let { formatBatteryDurationShort(context, it) }
|
||||
StatusChip(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = untilCharged
|
||||
?.let { "${stringResource(R.string.pods_charging_label)} · $it" }
|
||||
?: stringResource(R.string.pods_charging_label),
|
||||
)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
if (device.isBeingWorn == true) {
|
||||
|
||||
@@ -71,6 +71,7 @@ fun StatusChipRow(
|
||||
inEarLabel: String,
|
||||
microphoneLabel: String,
|
||||
modifier: Modifier = Modifier,
|
||||
chargingDetail: String? = null,
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = modifier.animateContentSize(),
|
||||
@@ -82,9 +83,11 @@ fun StatusChipRow(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = chargingOptimizedLabel,
|
||||
)
|
||||
// The time-until-charged lives INSIDE the charging chip ("Charging · 25m") — the
|
||||
// estimate line under the percentage always means runtime, so the two can't be confused.
|
||||
AapPodState.ChargingState.CHARGING -> StatusChip(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = chargingLabel,
|
||||
label = chargingDetail?.let { "$chargingLabel · $it" } ?: chargingLabel,
|
||||
)
|
||||
else -> Unit
|
||||
}
|
||||
@@ -122,6 +125,7 @@ private fun StatusChipRowAllPreview() = PreviewWrapper {
|
||||
chargingOptimizedLabel = "Optimized",
|
||||
inEarLabel = "In Ear",
|
||||
microphoneLabel = "Mic",
|
||||
chargingDetail = "25m",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ data class BatteryEstimate(
|
||||
* bounded by the model's rated life
|
||||
* @property minutesUntilCharged minutes until this pod is full — non-null only while it is
|
||||
* actively charging with a usable charge rate (not during an Optimized Battery Charging hold
|
||||
* or the final trickle phase). When set, the UI shows this instead of [minutesRemaining].
|
||||
* or the final trickle phase). Shown inside the charging chip; [minutesRemaining] stays on
|
||||
* the gauge line as the runtime projection.
|
||||
*/
|
||||
data class Pod(
|
||||
val minutesRemaining: Int,
|
||||
|
||||
@@ -5,33 +5,49 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Derives a rough battery-health percentage from learned drain rates vs the model's rated battery
|
||||
* life. Nothing on the wire exposes Apple's real health/cycle data, so this is a usage-based proxy:
|
||||
* a battery that only lasts 4.5h of a rated 6h reads as ~75%.
|
||||
* Derives a rough per-pod battery-health percentage from learned drain rates vs the model's rated
|
||||
* battery life. Nothing on the wire exposes Apple's real health/cycle data, so this is a usage-based
|
||||
* proxy: a pod that only lasts 4.5h of a rated 6h reads as ~75%.
|
||||
*
|
||||
* The MEDIAN of the qualifying learned rates is used rather than the best or worst: sessions where
|
||||
* the pods idled (in-ear, nothing playing) drain slower than the listening rating and would pull a
|
||||
* "best" pick to a meaningless 100%, while call-heavy or cold sessions drain faster and would drag
|
||||
* a "worst" pick into false doom. The median lands between both confounds. It remains an estimate —
|
||||
* label it as such in the UI.
|
||||
* Health is computed PER POD — single-pod listening habits or a replaced earbud make the two sides
|
||||
* genuinely diverge, and a combined figure would mask a failing pod. Within a pod, the MEDIAN of its
|
||||
* qualifying learned rates is used rather than the best or worst: sessions where the pod idled
|
||||
* (in-ear, nothing playing) drain slower than the listening rating and would pull a "best" pick to a
|
||||
* meaningless 100%, while call-heavy or cold sessions drain faster and would drag a "worst" pick
|
||||
* into false doom. The median lands between both confounds. It remains an estimate — label it as
|
||||
* such in the UI.
|
||||
*/
|
||||
object BatteryHealth {
|
||||
|
||||
/** A learned rate must have accumulated this many separate sessions before it counts. */
|
||||
const val MIN_UPDATE_COUNT = 3
|
||||
|
||||
private val VALID_SLOTS = setOf("LEFT", "RIGHT", "HEADSET")
|
||||
data class PerPod(
|
||||
val left: Int? = null,
|
||||
val right: Int? = null,
|
||||
val headset: Int? = null,
|
||||
) {
|
||||
val hasAny: Boolean get() = left != null || right != null || headset != null
|
||||
}
|
||||
|
||||
fun estimatePercent(profile: DrainProfile?, model: PodModel): Int? {
|
||||
fun estimate(profile: DrainProfile?, model: PodModel): PerPod? {
|
||||
if (profile == null) return null
|
||||
val spec = model.batterySpec ?: return null
|
||||
if (!profile.matchesModel(model)) return null
|
||||
|
||||
return PerPod(
|
||||
left = slotPercent(profile, spec, "LEFT"),
|
||||
right = slotPercent(profile, spec, "RIGHT"),
|
||||
headset = slotPercent(profile, spec, "HEADSET"),
|
||||
).takeIf { it.hasAny }
|
||||
}
|
||||
|
||||
private fun slotPercent(profile: DrainProfile, spec: PodModel.BatterySpec, slot: String): Int? {
|
||||
val ratios = profile.rates.mapNotNull { (key, rate) ->
|
||||
// Keys must be exactly "<bucket>/<slot>" with a known slot — anything else is corrupted
|
||||
// or future-format data and must not feed a health figure.
|
||||
// Keys must be exactly "<bucket>/<slot>" — anything else is corrupted or
|
||||
// future-format data and must not feed a health figure.
|
||||
val parts = key.split('/')
|
||||
if (parts.size != 2 || parts[1] !in VALID_SLOTS) return@mapNotNull null
|
||||
if (parts.size != 2 || parts[1] != slot) return@mapNotNull null
|
||||
val specHours = specHoursFor(spec, parts[0]) ?: return@mapNotNull null
|
||||
if (rate.updateCount < MIN_UPDATE_COUNT) return@mapNotNull null
|
||||
if (!rate.fractionPerHour.isFinite() || rate.fractionPerHour <= 0f) return@mapNotNull null
|
||||
|
||||
@@ -319,7 +319,6 @@
|
||||
<string name="battery_time_remaining_format_hm">%1$dh %2$dm</string>
|
||||
<string name="battery_time_remaining_format_h">%1$dh</string>
|
||||
<string name="battery_time_remaining_format_m">%1$dm</string>
|
||||
<string name="battery_time_until_charged_short">⚡ %1$s</string>
|
||||
<string name="permission_post_notifications_label">Show notifications</string>
|
||||
<string name="permission_post_notifications_description">"Allow CAPod to show notifications about your AirPods, e.g. their current status while connected."</string>
|
||||
|
||||
@@ -477,7 +476,9 @@
|
||||
<string name="device_settings_info_right_serial_label">Right Pod Serial</string>
|
||||
<string name="device_settings_info_left_bonded_label">Left Bonded</string>
|
||||
<string name="device_settings_info_right_bonded_label">Right Bonded</string>
|
||||
<string name="device_settings_info_battery_health_label">Battery health (estimated)</string>
|
||||
<string name="device_settings_info_battery_health_label">Battery Health (estimated)</string>
|
||||
<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_details_label">Device Details</string>
|
||||
<string name="device_settings_info_details_action">Show device details</string>
|
||||
|
||||
Reference in New Issue
Block a user