mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
feat(battery): Show battery estimates in widget and expanded notification
This commit is contained in:
@@ -21,11 +21,13 @@ import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||
import eu.darken.capod.common.theming.CapodTheme
|
||||
import eu.darken.capod.main.ui.overview.OverviewScreen
|
||||
import eu.darken.capod.main.ui.overview.OverviewViewModel
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import eu.darken.capod.main.ui.widget.ComposeWidgetPreview
|
||||
import eu.darken.capod.main.ui.widget.WidgetConfigurationScreen
|
||||
import eu.darken.capod.main.ui.widget.WidgetConfigurationViewModel
|
||||
import eu.darken.capod.main.ui.widget.WidgetRenderState
|
||||
import eu.darken.capod.main.ui.widget.WidgetTheme
|
||||
import eu.darken.capod.main.ui.widget.withLocalizedPreviewEstimates
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.profiles.ui.DeviceManagerScreen
|
||||
import eu.darken.capod.profiles.ui.DeviceManagerViewModel
|
||||
@@ -229,7 +231,7 @@ internal fun HomescreenWidgetContent() {
|
||||
bgColor = MaterialTheme.colorScheme.surface.toArgb(),
|
||||
textColor = MaterialTheme.colorScheme.onSurface.toArgb(),
|
||||
iconColor = MaterialTheme.colorScheme.onSurface.toArgb(),
|
||||
),
|
||||
).withLocalizedPreviewEstimates(LocalContext.current),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,12 +15,16 @@ import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.main.ui.widget.WidgetManager
|
||||
import eu.darken.capod.main.ui.widget.toWidgetKey
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimator
|
||||
import eu.darken.capod.monitor.core.battery.displayKey
|
||||
import eu.darken.capod.monitor.core.battery.estimateFor
|
||||
|
||||
import eu.darken.capod.monitor.core.devicesWithProfiles
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -36,6 +40,7 @@ open class App : Application() {
|
||||
@Inject lateinit var deviceMonitor: DeviceMonitor
|
||||
@Inject lateinit var widgetManager: WidgetManager
|
||||
@Inject lateinit var upgradeRepo: UpgradeRepo
|
||||
@Inject lateinit var batteryEstimator: BatteryEstimator
|
||||
@Inject @AppScope lateinit var appScope: CoroutineScope
|
||||
|
||||
override fun onCreate() {
|
||||
@@ -62,8 +67,15 @@ open class App : Application() {
|
||||
|
||||
appScope.launch { widgetManager.refreshWidgets() }
|
||||
|
||||
deviceMonitor.devicesWithProfiles()
|
||||
.distinctUntilChangedBy { devices -> devices.map { it.toWidgetKey() } }
|
||||
combine(
|
||||
deviceMonitor.devicesWithProfiles(),
|
||||
batteryEstimator.estimates,
|
||||
) { devices, estimates -> devices to estimates }
|
||||
// Estimate display values can change while the device key is stable (e.g. a charge
|
||||
// ETA gets suppressed after a stall) — refresh on those too, but only on visible ones.
|
||||
.distinctUntilChangedBy { (devices, estimates) ->
|
||||
devices.map { device -> device.toWidgetKey() to estimates.estimateFor(device)?.displayKey(device) }
|
||||
}
|
||||
.throttleLatest(1000)
|
||||
.onEach {
|
||||
log(TAG, VERBOSE) { "Devices changed, refreshing widgets." }
|
||||
|
||||
@@ -25,6 +25,7 @@ import eu.darken.capod.monitor.core.MonitorModeResolver
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimator
|
||||
import eu.darken.capod.monitor.core.battery.estimateFor
|
||||
import eu.darken.capod.monitor.core.tierRank
|
||||
import eu.darken.capod.monitor.core.worker.MonitorControl
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
@@ -247,12 +248,7 @@ class OverviewViewModel @Inject constructor(
|
||||
* Time-remaining estimate to show for [device], or null when the device has the estimate
|
||||
* disabled, isn't live (no estimate for cached/offline cards), or no rate is available yet.
|
||||
*/
|
||||
fun estimateFor(device: PodDevice): BatteryEstimate? {
|
||||
if (!device.batteryEstimateEnabled) return null
|
||||
if (!device.isLive) return null
|
||||
val profileId = device.profileId ?: return null
|
||||
return batteryEstimates[profileId]
|
||||
}
|
||||
fun estimateFor(device: PodDevice): BatteryEstimate? = batteryEstimates.estimateFor(device)
|
||||
|
||||
/** Profile list order used as tiebreaker within each connection tier. */
|
||||
private val profileOrder: Map<String, Int> by lazy {
|
||||
|
||||
@@ -24,8 +24,13 @@ import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.common.upgrade.isPro
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimator
|
||||
import eu.darken.capod.monitor.core.battery.takeFor
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
@@ -40,6 +45,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
fun upgradeRepo(): UpgradeRepo
|
||||
fun widgetSettings(): WidgetSettings
|
||||
fun deviceProfilesRepo(): DeviceProfilesRepo
|
||||
fun batteryEstimator(): BatteryEstimator
|
||||
}
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
@@ -99,6 +105,13 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
?.let { pid -> remember(pid) { ep.deviceMonitor().widgetDeviceFlow(pid) } }
|
||||
?.collectAsState(initial = initialDevice)
|
||||
?: remember(initialDevice) { androidx.compose.runtime.mutableStateOf(initialDevice) }
|
||||
// Empty while the monitor service isn't running — estimates simply stay hidden.
|
||||
val rawEstimate by config.profileId
|
||||
?.let { pid ->
|
||||
remember(pid) { ep.batteryEstimator().estimates.map { it[pid] }.distinctUntilChanged() }
|
||||
}
|
||||
?.collectAsState(initial = null)
|
||||
?: remember { androidx.compose.runtime.mutableStateOf<BatteryEstimate?>(null) }
|
||||
val profileLabel = config.profileId?.let { pid ->
|
||||
runCatching {
|
||||
runBlocking { ep.deviceProfilesRepo().profiles.first().firstOrNull { it.id == pid }?.label }
|
||||
@@ -117,6 +130,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
hasConfiguredProfile = config.profileId != null,
|
||||
profileLabel = profileLabel,
|
||||
layout = layout,
|
||||
estimate = device?.let { d -> rawEstimate?.takeFor(d) },
|
||||
)
|
||||
} else {
|
||||
WidgetRenderState.Message(
|
||||
@@ -138,7 +152,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
bgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
|
||||
textColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
|
||||
iconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
|
||||
)
|
||||
).withLocalizedPreviewEstimates(context)
|
||||
|
||||
provideContent {
|
||||
GlanceWidgetContent(state = previewState, context = context)
|
||||
|
||||
@@ -96,9 +96,9 @@ private fun DualPodPreview(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint, iconSize = 40, modifier = Modifier.padding(end = 12.dp))
|
||||
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint, iconSize = 40, estimate = state.leftEstimate, estimateBelow = true, modifier = Modifier.padding(end = 12.dp))
|
||||
PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint, iconSize = 40, modifier = Modifier.padding(end = 12.dp))
|
||||
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, iconSize = 40)
|
||||
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, iconSize = 40, estimate = state.rightEstimate, estimateBelow = true)
|
||||
}
|
||||
DeviceLabel(
|
||||
label = state.deviceLabel,
|
||||
@@ -111,8 +111,8 @@ private fun DualPodPreview(
|
||||
|
||||
BatteryLayout.NARROW -> {
|
||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint)
|
||||
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint)
|
||||
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint, estimate = state.leftEstimate)
|
||||
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, estimate = state.rightEstimate)
|
||||
PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint)
|
||||
DeviceLabel(
|
||||
label = state.deviceLabel,
|
||||
@@ -160,9 +160,10 @@ private fun SinglePodPreview(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatPercent(state.percent),
|
||||
text = formatPercent(state.percent) + estimateSuffix(state.estimate),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
if (state.charging) {
|
||||
@@ -297,39 +298,54 @@ private fun PodItemRow(
|
||||
textColor: Color,
|
||||
iconTint: ColorFilter,
|
||||
iconSize: Int = 20,
|
||||
estimate: String? = null,
|
||||
estimateBelow: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(iconSize.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatPercent(percent),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
if (charging) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_baseline_power_24),
|
||||
painter = painterResource(icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
modifier = Modifier.size(iconSize.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatPercent(percent) + if (estimateBelow) "" else estimateSuffix(estimate),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
if (charging) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_baseline_power_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
if (inEar) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_baseline_hearing_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (inEar) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_baseline_hearing_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
if (estimateBelow && estimate != null) {
|
||||
Text(
|
||||
text = estimate,
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -385,6 +401,8 @@ private fun DeviceLabel(
|
||||
private fun formatPercent(percent: Float): String =
|
||||
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else "—"
|
||||
|
||||
private fun estimateSuffix(estimate: String?): String = estimate?.let { " · $it" } ?: ""
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun PreviewDualTiny11() = PreviewWrapper {
|
||||
|
||||
@@ -65,9 +65,9 @@ private fun GlanceDualPod(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
|
||||
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, iconSize = 40, estimate = state.leftEstimate, estimateBelow = true, modifier = GlanceModifier.padding(end = 12.dp))
|
||||
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
|
||||
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, iconSize = 40)
|
||||
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, iconSize = 40, estimate = state.rightEstimate, estimateBelow = true)
|
||||
}
|
||||
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
|
||||
}
|
||||
@@ -76,8 +76,8 @@ private fun GlanceDualPod(
|
||||
BatteryLayout.NARROW -> {
|
||||
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
|
||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint)
|
||||
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint)
|
||||
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, estimate = state.leftEstimate)
|
||||
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, estimate = state.rightEstimate)
|
||||
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint)
|
||||
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
|
||||
}
|
||||
@@ -122,8 +122,9 @@ private fun GlanceSinglePod(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatGlancePercent(state.percent),
|
||||
text = formatGlancePercent(state.percent) + estimateSuffix(state.estimate),
|
||||
style = textStyle,
|
||||
maxLines = 1,
|
||||
modifier = GlanceModifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
if (state.charging) {
|
||||
@@ -257,37 +258,49 @@ private fun GlancePodItem(
|
||||
textStyle: TextStyle,
|
||||
iconTint: ColorFilter,
|
||||
iconSize: Int = 20,
|
||||
estimate: String? = null,
|
||||
estimateBelow: Boolean = false,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
) {
|
||||
Row(
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(icon),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(iconSize.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatGlancePercent(percent),
|
||||
style = textStyle,
|
||||
modifier = GlanceModifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
if (charging) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_baseline_power_24),
|
||||
provider = ImageProvider(icon),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
modifier = GlanceModifier.size(iconSize.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatGlancePercent(percent) + if (estimateBelow) "" else estimateSuffix(estimate),
|
||||
style = textStyle,
|
||||
maxLines = 1,
|
||||
modifier = GlanceModifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
if (charging) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_baseline_power_24),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
if (inEar) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_baseline_hearing_24),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (inEar) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_baseline_hearing_24),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
if (estimateBelow && estimate != null) {
|
||||
Text(
|
||||
text = estimate,
|
||||
style = textStyle,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -343,3 +356,5 @@ private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb))
|
||||
|
||||
private fun formatGlancePercent(percent: Float): String =
|
||||
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else "—"
|
||||
|
||||
private fun estimateSuffix(estimate: String?): String = estimate?.let { " · $it" } ?: ""
|
||||
|
||||
@@ -507,7 +507,7 @@ private fun WidgetConfigPreview(
|
||||
bgColor = WidgetRenderStateMapper.resolvedBgColor(context, theme),
|
||||
textColor = WidgetRenderStateMapper.resolvedTextColor(context, theme),
|
||||
iconColor = WidgetRenderStateMapper.resolvedIconColor(context, theme),
|
||||
).copy(deviceLabel = deviceLabel)
|
||||
).withLocalizedPreviewEstimates(context).copy(deviceLabel = deviceLabel)
|
||||
}
|
||||
ComposeWidgetPreview(state = batteryState, modifier = innerModifier)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ internal data class WidgetDeviceKey(
|
||||
val isLeftInEar: Boolean?,
|
||||
val isRightInEar: Boolean?,
|
||||
val isBeingWorn: Boolean?,
|
||||
val batteryEstimateEnabled: Boolean,
|
||||
val isAapConnected: Boolean,
|
||||
val isAapReady: Boolean,
|
||||
val hasBleAdvertisement: Boolean,
|
||||
@@ -48,6 +49,7 @@ internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey(
|
||||
isLeftInEar = isLeftInEar,
|
||||
isRightInEar = isRightInEar,
|
||||
isBeingWorn = isBeingWorn,
|
||||
batteryEstimateEnabled = batteryEstimateEnabled,
|
||||
isAapConnected = isAapConnected,
|
||||
isAapReady = isAapReady,
|
||||
hasBleAdvertisement = ble != null,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.DrawableRes
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
|
||||
|
||||
enum class BatteryLayout {
|
||||
TINY_COLUMN,
|
||||
@@ -36,10 +38,12 @@ sealed class WidgetRenderState {
|
||||
val leftPercent: Float,
|
||||
val leftCharging: Boolean,
|
||||
val leftInEar: Boolean,
|
||||
val leftEstimate: String?,
|
||||
@DrawableRes val rightIcon: Int,
|
||||
val rightPercent: Float,
|
||||
val rightCharging: Boolean,
|
||||
val rightInEar: Boolean,
|
||||
val rightEstimate: String?,
|
||||
@DrawableRes val caseIcon: Int,
|
||||
val casePercent: Float,
|
||||
val caseCharging: Boolean,
|
||||
@@ -57,6 +61,7 @@ sealed class WidgetRenderState {
|
||||
@DrawableRes val batteryIcon: Int,
|
||||
val charging: Boolean,
|
||||
val worn: Boolean,
|
||||
val estimate: String?,
|
||||
) : WidgetRenderState()
|
||||
|
||||
data class Message(
|
||||
@@ -95,10 +100,12 @@ sealed class WidgetRenderState {
|
||||
leftPercent = 0.85f,
|
||||
leftCharging = false,
|
||||
leftInEar = true,
|
||||
leftEstimate = "4h 55m",
|
||||
rightIcon = R.drawable.device_airpods_pro2_right,
|
||||
rightPercent = 0.92f,
|
||||
rightCharging = true,
|
||||
rightInEar = false,
|
||||
rightEstimate = "25m",
|
||||
caseIcon = R.drawable.device_airpods_pro2_case,
|
||||
casePercent = 1.0f,
|
||||
caseCharging = false,
|
||||
@@ -122,6 +129,16 @@ sealed class WidgetRenderState {
|
||||
batteryIcon = R.drawable.ic_baseline_battery_3_bar_24,
|
||||
charging = false,
|
||||
worn = true,
|
||||
estimate = "14h 30m",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the preview factory's hardcoded sample estimates with properly localized ones for
|
||||
* user-facing preview surfaces (widget picker, configuration screen, store screenshots).
|
||||
*/
|
||||
fun WidgetRenderState.DualPod.withLocalizedPreviewEstimates(context: Context): WidgetRenderState.DualPod = copy(
|
||||
leftEstimate = formatBatteryDurationShort(context, 295),
|
||||
rightEstimate = formatBatteryDurationShort(context, 25),
|
||||
)
|
||||
|
||||
@@ -5,7 +5,10 @@ import androidx.annotation.ColorInt
|
||||
import androidx.appcompat.view.ContextThemeWrapper
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||
import eu.darken.capod.monitor.core.battery.displayMinutes
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
|
||||
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
||||
|
||||
object WidgetRenderStateMapper {
|
||||
@@ -18,6 +21,7 @@ object WidgetRenderStateMapper {
|
||||
hasConfiguredProfile: Boolean,
|
||||
profileLabel: String?,
|
||||
layout: BatteryLayout = BatteryLayout.NARROW,
|
||||
estimate: BatteryEstimate? = null,
|
||||
): WidgetRenderState {
|
||||
val bgColor = resolvedBgColor(context, theme)
|
||||
val textColor = resolvedTextColor(context, theme)
|
||||
@@ -45,10 +49,12 @@ object WidgetRenderStateMapper {
|
||||
leftPercent = device.batteryLeft,
|
||||
leftCharging = device.isLeftPodCharging == true,
|
||||
leftInEar = device.isLeftInEar == true,
|
||||
leftEstimate = estimate?.left.estimateText(context, device.isLeftPodCharging == true),
|
||||
rightIcon = device.rightPodIcon,
|
||||
rightPercent = device.batteryRight,
|
||||
rightCharging = device.isRightPodCharging == true,
|
||||
rightInEar = device.isRightInEar == true,
|
||||
rightEstimate = estimate?.right.estimateText(context, device.isRightPodCharging == true),
|
||||
caseIcon = device.caseIcon,
|
||||
casePercent = device.batteryCase,
|
||||
caseCharging = device.isCaseCharging == true,
|
||||
@@ -66,6 +72,7 @@ object WidgetRenderStateMapper {
|
||||
batteryIcon = getBatteryDrawable(device.batteryHeadset),
|
||||
charging = device.isHeadsetBeingCharged == true,
|
||||
worn = device.isBeingWorn == true,
|
||||
estimate = estimate?.headset.estimateText(context, device.isHeadsetBeingCharged == true),
|
||||
)
|
||||
|
||||
device != null -> WidgetRenderState.Message(
|
||||
@@ -95,6 +102,9 @@ object WidgetRenderStateMapper {
|
||||
}
|
||||
}
|
||||
|
||||
private fun BatteryEstimate.Pod?.estimateText(context: Context, charging: Boolean): String? =
|
||||
this?.displayMinutes(charging)?.let { formatBatteryDurationShort(context, it) }
|
||||
|
||||
@ColorInt
|
||||
fun resolvedBgColor(context: Context, theme: WidgetTheme): Int {
|
||||
val bgColor = theme.backgroundColor
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package eu.darken.capod.monitor.core.battery
|
||||
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
|
||||
/**
|
||||
* Returns this estimate only if [device] should display it: the per-device toggle is on, the
|
||||
* device is live (no estimates for cached/offline cards), and it maps to a profile.
|
||||
*/
|
||||
fun BatteryEstimate.takeFor(device: PodDevice): BatteryEstimate? = takeIf {
|
||||
device.batteryEstimateEnabled && device.isLive && device.profileId != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up and gates the estimate to show for [device]. Shared by all surfaces (dashboard,
|
||||
* widget, notification) so the display rules can't drift apart.
|
||||
*/
|
||||
fun Map<String, BatteryEstimate>.estimateFor(device: PodDevice): BatteryEstimate? =
|
||||
device.profileId?.let { this[it] }?.takeFor(device)
|
||||
|
||||
/**
|
||||
* Minutes to display for one pod: the time-until-full while [charging] (null when no usable
|
||||
* charge ETA exists, e.g. during an Optimized Battery Charging hold), otherwise the runtime
|
||||
* projection. [charging] should be the same flag that drives the visible charging indicator,
|
||||
* so the shown duration never contradicts the bolt icon next to it.
|
||||
*/
|
||||
fun BatteryEstimate.Pod.displayMinutes(charging: Boolean): Int? = when {
|
||||
charging -> minutesUntilCharged
|
||||
else -> minutesRemaining
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-slot minutes a surface would actually render for [device], or null when no slot shows
|
||||
* anything. Everything else on the estimate (rates, source) churns without visible effect —
|
||||
* dedupe render triggers on this key so invisible changes don't refresh widgets/notifications.
|
||||
*/
|
||||
fun BatteryEstimate.displayKey(device: PodDevice): List<Int?>? = listOf(
|
||||
left?.displayMinutes(device.isLeftPodCharging == true),
|
||||
right?.displayMinutes(device.isRightPodCharging == true),
|
||||
headset?.displayMinutes(device.isHeadsetBeingCharged == true),
|
||||
).takeUnless { key -> key.all { it == null } }
|
||||
@@ -29,7 +29,10 @@ import eu.darken.capod.main.core.MonitorMode
|
||||
import eu.darken.capod.main.core.PermissionTool
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.MonitorCoroutineScope
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimator
|
||||
import eu.darken.capod.monitor.core.battery.displayKey
|
||||
import eu.darken.capod.monitor.core.battery.estimateFor
|
||||
import eu.darken.capod.monitor.core.MonitorModeResolver
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.ble.BlePodMonitor
|
||||
@@ -224,16 +227,34 @@ class MonitorService : Service() {
|
||||
NotificationSettings(useExtraNotification = useExtra, keepAfterDisconnect = keepAfter)
|
||||
}
|
||||
|
||||
val monitorJob = combine(deviceFlow, notificationSettingsFlow) { currentDevice, settings ->
|
||||
currentDevice to settings
|
||||
val monitorJob = combine(
|
||||
deviceFlow,
|
||||
notificationSettingsFlow,
|
||||
batteryEstimator.estimates,
|
||||
) { currentDevice, settings, estimates ->
|
||||
NotificationInput(
|
||||
device = currentDevice,
|
||||
settings = settings,
|
||||
estimate = currentDevice?.let { estimates.estimateFor(it) },
|
||||
)
|
||||
}
|
||||
.onEach { (currentDevice, settings) ->
|
||||
// The estimates map churns on fields the notification doesn't display (and for other
|
||||
// profiles' devices) — only re-notify when something visible changed.
|
||||
.distinctUntilChangedBy { input ->
|
||||
Triple(
|
||||
input.device?.toNotificationKey(),
|
||||
input.settings,
|
||||
input.device?.let { input.estimate?.displayKey(it) },
|
||||
)
|
||||
}
|
||||
.onEach { (currentDevice, settings, estimate) ->
|
||||
latestNotificationSettings = settings
|
||||
|
||||
notificationManager.notify(
|
||||
MonitorNotifications.NOTIFICATION_ID,
|
||||
notifications.getNotification(
|
||||
currentDevice,
|
||||
estimate = estimate,
|
||||
showHint = settings.useExtraNotification,
|
||||
),
|
||||
)
|
||||
@@ -241,7 +262,7 @@ class MonitorService : Service() {
|
||||
when (val action = decideExtraNotificationAction(currentDevice, settings)) {
|
||||
is ExtraNotificationAction.Post -> notificationManager.notify(
|
||||
MonitorNotifications.NOTIFICATION_ID_CONNECTED,
|
||||
notifications.getNotificationConnected(action.device),
|
||||
notifications.getNotificationConnected(action.device, estimate),
|
||||
)
|
||||
ExtraNotificationAction.Cancel -> notificationManager.cancel(
|
||||
MonitorNotifications.NOTIFICATION_ID_CONNECTED
|
||||
@@ -430,6 +451,12 @@ internal fun buildMonitorModeState(
|
||||
hasAapSession = aapStates.isNotEmpty(),
|
||||
)
|
||||
|
||||
internal data class NotificationInput(
|
||||
val device: PodDevice?,
|
||||
val settings: NotificationSettings,
|
||||
val estimate: BatteryEstimate?,
|
||||
)
|
||||
|
||||
private data class NotificationDeviceKey(
|
||||
val profileId: String?,
|
||||
val label: String?,
|
||||
@@ -449,6 +476,7 @@ private data class NotificationDeviceKey(
|
||||
val isLeftInEar: Boolean?,
|
||||
val isRightInEar: Boolean?,
|
||||
val isBeingWorn: Boolean?,
|
||||
val batteryEstimateEnabled: Boolean,
|
||||
val iconRes: Int,
|
||||
val leftPodIcon: Int,
|
||||
val rightPodIcon: Int,
|
||||
@@ -474,6 +502,7 @@ private fun PodDevice.toNotificationKey(): NotificationDeviceKey = NotificationD
|
||||
isLeftInEar = isLeftInEar,
|
||||
isRightInEar = isRightInEar,
|
||||
isBeingWorn = isBeingWorn,
|
||||
batteryEstimateEnabled = batteryEstimateEnabled,
|
||||
iconRes = iconRes,
|
||||
leftPodIcon = leftPodIcon,
|
||||
rightPodIcon = rightPodIcon,
|
||||
|
||||
@@ -6,7 +6,10 @@ import android.widget.RemoteViews
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||
import eu.darken.capod.monitor.core.battery.displayMinutes
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
||||
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||
@@ -83,13 +86,13 @@ class MonitorNotificationViewFactory @Inject constructor(
|
||||
setTextViewText(R.id.device, device.getLabel(context))
|
||||
}
|
||||
|
||||
fun createBigContentView(device: PodDevice): RemoteViews = when {
|
||||
device.hasDualPods -> createDualPodsBig(device)
|
||||
device.model != PodModel.UNKNOWN -> createSinglePodBig(device)
|
||||
fun createBigContentView(device: PodDevice, estimate: BatteryEstimate? = null): RemoteViews = when {
|
||||
device.hasDualPods -> createDualPodsBig(device, estimate)
|
||||
device.model != PodModel.UNKNOWN -> createSinglePodBig(device, estimate)
|
||||
else -> createUnknownDeviceBig(device)
|
||||
}
|
||||
|
||||
private fun createDualPodsBig(device: PodDevice): RemoteViews = RemoteViews(
|
||||
private fun createDualPodsBig(device: PodDevice, estimate: BatteryEstimate?): RemoteViews = RemoteViews(
|
||||
context.packageName,
|
||||
R.layout.monitor_notification_dual_pods_big
|
||||
).apply {
|
||||
@@ -97,7 +100,11 @@ class MonitorNotificationViewFactory @Inject constructor(
|
||||
val leftPercent = device.batteryLeft
|
||||
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))
|
||||
setTextViewText(
|
||||
R.id.pod_left_label,
|
||||
formatBatteryPercent(context, leftPercent) +
|
||||
estimateSuffix(estimate?.left, device.isLeftPodCharging == true)
|
||||
)
|
||||
val isLeftPodCharging = device.isLeftPodCharging ?: false
|
||||
setViewVisibility(R.id.pod_left_charging, if (isLeftPodCharging) View.VISIBLE else View.GONE)
|
||||
val isLeftPodInEar = device.isLeftInEar ?: false
|
||||
@@ -117,14 +124,18 @@ class MonitorNotificationViewFactory @Inject constructor(
|
||||
val rightPercent = device.batteryRight
|
||||
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))
|
||||
setTextViewText(
|
||||
R.id.pod_right_label,
|
||||
formatBatteryPercent(context, rightPercent) +
|
||||
estimateSuffix(estimate?.right, device.isRightPodCharging == true)
|
||||
)
|
||||
val isRightPodCharging = device.isRightPodCharging ?: false
|
||||
setViewVisibility(R.id.pod_right_charging, if (isRightPodCharging) View.VISIBLE else View.GONE)
|
||||
val isRightPodInEar = device.isRightInEar ?: false
|
||||
setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE)
|
||||
}
|
||||
|
||||
private fun createSinglePodBig(device: PodDevice): RemoteViews = RemoteViews(
|
||||
private fun createSinglePodBig(device: PodDevice, estimate: BatteryEstimate?): RemoteViews = RemoteViews(
|
||||
context.packageName,
|
||||
R.layout.monitor_notification_single_pods_big
|
||||
).apply {
|
||||
@@ -132,7 +143,11 @@ class MonitorNotificationViewFactory @Inject constructor(
|
||||
setTextViewText(R.id.headphones_label, device.getLabel(context))
|
||||
setImageViewResource(R.id.headphones_icon, device.iconRes)
|
||||
setProgressBar(R.id.headphones_battery_progress, 100, percentToInt(headsetPercent), false)
|
||||
setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent))
|
||||
setTextViewText(
|
||||
R.id.headphones_battery_label,
|
||||
formatBatteryPercent(context, headsetPercent) +
|
||||
estimateSuffix(estimate?.headset, device.isHeadsetBeingCharged == true)
|
||||
)
|
||||
if (device.hasEarDetection) {
|
||||
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn == true) View.VISIBLE else View.GONE)
|
||||
}
|
||||
@@ -154,4 +169,7 @@ class MonitorNotificationViewFactory @Inject constructor(
|
||||
private fun percentToInt(percent: Float): Int =
|
||||
if (isKnownBattery(percent)) (percent * 100).roundToInt().coerceIn(0, 100) else 0
|
||||
|
||||
private fun estimateSuffix(pod: BatteryEstimate.Pod?, charging: Boolean): String =
|
||||
pod?.displayMinutes(charging)?.let { " · ${formatBatteryDurationShort(context, it)}" } ?: ""
|
||||
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.notifications.PendingIntentCompat
|
||||
import eu.darken.capod.main.ui.MainActivity
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||
import javax.inject.Inject
|
||||
@@ -56,7 +57,8 @@ class MonitorNotifications @Inject constructor(
|
||||
private fun getBuilder(
|
||||
device: PodDevice?,
|
||||
channelId: String,
|
||||
showHint: Boolean = false
|
||||
estimate: BatteryEstimate? = null,
|
||||
showHint: Boolean = false,
|
||||
): NotificationCompat.Builder {
|
||||
if (device == null) {
|
||||
return baseBuilder(channelId).apply {
|
||||
@@ -119,18 +121,21 @@ class MonitorNotifications @Inject constructor(
|
||||
|
||||
setStyle(NotificationCompat.DecoratedCustomViewStyle())
|
||||
setCustomContentView(notificationViewFactory.createContentView(device))
|
||||
setCustomBigContentView(notificationViewFactory.createBigContentView(device))
|
||||
setCustomBigContentView(notificationViewFactory.createBigContentView(device, estimate))
|
||||
setContentTitle("$batteryText ~ $stateText")
|
||||
setSubText(null)
|
||||
log(TAG, VERBOSE) { "updatingNotification(): $device" }
|
||||
}
|
||||
}
|
||||
|
||||
fun getNotification(podDevice: PodDevice?, showHint: Boolean = false): Notification =
|
||||
getBuilder(podDevice, NOTIFICATION_CHANNEL_ID, showHint).build()
|
||||
fun getNotification(
|
||||
podDevice: PodDevice?,
|
||||
estimate: BatteryEstimate? = null,
|
||||
showHint: Boolean = false,
|
||||
): Notification = getBuilder(podDevice, NOTIFICATION_CHANNEL_ID, estimate, showHint).build()
|
||||
|
||||
fun getNotificationConnected(podDevice: PodDevice?): Notification =
|
||||
getBuilder(podDevice, NOTIFICATION_CHANNEL_ID_CONNECTED).build()
|
||||
fun getNotificationConnected(podDevice: PodDevice?, estimate: BatteryEstimate? = null): Notification =
|
||||
getBuilder(podDevice, NOTIFICATION_CHANNEL_ID_CONNECTED, estimate).build()
|
||||
|
||||
fun getStartupNotification(): Notification =
|
||||
getBuilder(null, NOTIFICATION_CHANNEL_ID).build()
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
tools:text="95%" />
|
||||
tools:text="95% · 5h 40m" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="36dp"
|
||||
@@ -163,7 +163,7 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
tools:text="92%" />
|
||||
tools:text="92% · 5h 25m" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="36dp"
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
tools:text="95%" />
|
||||
tools:text="95% · 14h 30m" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/headphones_charging"
|
||||
|
||||
@@ -73,6 +73,14 @@ class WidgetDeviceKeyTest : BaseTest() {
|
||||
.toWidgetKey() shouldNotBe base
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `battery estimate toggle alters key`() {
|
||||
val enabled = dualDevice().toWidgetKey()
|
||||
val disabled = dualDevice(batteryEstimateEnabled = false).toWidgetKey()
|
||||
|
||||
disabled shouldNotBe enabled
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `labels and wear state alter key`() {
|
||||
val dualBase = dualDevice().toWidgetKey()
|
||||
@@ -126,6 +134,7 @@ class WidgetDeviceKeyTest : BaseTest() {
|
||||
label: String? = dualProfile.label,
|
||||
ble: BlePodSnapshot? = FakeDualBlePod(profile = dualProfile),
|
||||
aap: AapPodState? = null,
|
||||
batteryEstimateEnabled: Boolean = true,
|
||||
) = PodDevice(
|
||||
profileId = dualProfile.id,
|
||||
label = label,
|
||||
@@ -133,6 +142,7 @@ class WidgetDeviceKeyTest : BaseTest() {
|
||||
aap = aap,
|
||||
profileAddress = dualProfile.address,
|
||||
profileModel = dualProfile.model,
|
||||
batteryEstimateEnabled = batteryEstimateEnabled,
|
||||
)
|
||||
|
||||
private fun singleDevice(
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package eu.darken.capod.monitor.core.battery
|
||||
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.shouldNotBe
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
|
||||
class BatteryEstimateExtensionsTest : BaseTest() {
|
||||
|
||||
private val estimate = BatteryEstimate(
|
||||
left = BatteryEstimate.Pod(minutesRemaining = 120, fractionPerHour = 0.2f, source = BatteryEstimate.Source.LIVE),
|
||||
)
|
||||
|
||||
private fun device(
|
||||
profileId: String? = "profile-1",
|
||||
live: Boolean = true,
|
||||
enabled: Boolean = true,
|
||||
): PodDevice = mockk {
|
||||
every { this@mockk.profileId } returns profileId
|
||||
every { this@mockk.isLive } returns live
|
||||
every { this@mockk.batteryEstimateEnabled } returns enabled
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `estimateFor returns the profile's estimate for an enabled live device`() {
|
||||
mapOf("profile-1" to estimate).estimateFor(device()) shouldBe estimate
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `estimateFor is null when the per-device toggle is off`() {
|
||||
mapOf("profile-1" to estimate).estimateFor(device(enabled = false)).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `estimateFor is null for cached-only devices`() {
|
||||
mapOf("profile-1" to estimate).estimateFor(device(live = false)).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `estimateFor is null for anonymous devices`() {
|
||||
mapOf("profile-1" to estimate).estimateFor(device(profileId = null)).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `estimateFor is null when no estimate exists for the profile`() {
|
||||
mapOf("other-profile" to estimate).estimateFor(device()).shouldBeNull()
|
||||
emptyMap<String, BatteryEstimate>().estimateFor(device()).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `takeFor applies the same gating to an already-resolved estimate`() {
|
||||
estimate.takeFor(device()) shouldBe estimate
|
||||
estimate.takeFor(device(enabled = false)).shouldBeNull()
|
||||
estimate.takeFor(device(live = false)).shouldBeNull()
|
||||
estimate.takeFor(device(profileId = null)).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `displayMinutes shows the runtime projection while not charging`() {
|
||||
val pod = BatteryEstimate.Pod(
|
||||
minutesRemaining = 300,
|
||||
fractionPerHour = 0.2f,
|
||||
source = BatteryEstimate.Source.LIVE,
|
||||
minutesUntilCharged = 25,
|
||||
)
|
||||
pod.displayMinutes(charging = false) shouldBe 300
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `displayMinutes swaps to the charge ETA while charging`() {
|
||||
val pod = BatteryEstimate.Pod(
|
||||
minutesRemaining = 300,
|
||||
fractionPerHour = 0.2f,
|
||||
source = BatteryEstimate.Source.LIVE,
|
||||
minutesUntilCharged = 25,
|
||||
)
|
||||
pod.displayMinutes(charging = true) shouldBe 25
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `displayMinutes shows nothing while charging without a usable ETA`() {
|
||||
val pod = BatteryEstimate.Pod(
|
||||
minutesRemaining = 300,
|
||||
fractionPerHour = 0.2f,
|
||||
source = BatteryEstimate.Source.LIVE,
|
||||
minutesUntilCharged = null,
|
||||
)
|
||||
pod.displayMinutes(charging = true).shouldBeNull()
|
||||
}
|
||||
|
||||
private fun chargingDevice(
|
||||
leftCharging: Boolean = false,
|
||||
rightCharging: Boolean = false,
|
||||
headsetCharging: Boolean = false,
|
||||
): PodDevice = mockk {
|
||||
every { isLeftPodCharging } returns leftCharging
|
||||
every { isRightPodCharging } returns rightCharging
|
||||
every { isHeadsetBeingCharged } returns headsetCharging
|
||||
}
|
||||
|
||||
private fun pod(
|
||||
minutes: Int = 300,
|
||||
rate: Float = 0.2f,
|
||||
source: BatteryEstimate.Source = BatteryEstimate.Source.LIVE,
|
||||
untilCharged: Int? = null,
|
||||
) = BatteryEstimate.Pod(
|
||||
minutesRemaining = minutes,
|
||||
fractionPerHour = rate,
|
||||
source = source,
|
||||
minutesUntilCharged = untilCharged,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `invisible estimate churn keeps the display key stable`() {
|
||||
// Rate and source change constantly while the displayed minutes stay put — render
|
||||
// triggers deduped on the display key must not fire for those.
|
||||
val before = BatteryEstimate(left = pod(rate = 0.20f, source = BatteryEstimate.Source.LEARNED))
|
||||
val after = BatteryEstimate(left = pod(rate = 0.21f, source = BatteryEstimate.Source.LIVE))
|
||||
|
||||
before.displayKey(chargingDevice()) shouldBe after.displayKey(chargingDevice())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `displayed minutes changes alter the display key`() {
|
||||
val before = BatteryEstimate(left = pod(minutes = 300))
|
||||
val after = BatteryEstimate(left = pod(minutes = 299))
|
||||
|
||||
before.displayKey(chargingDevice()) shouldNotBe after.displayKey(chargingDevice())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `display key selects the charge ETA per charging slot`() {
|
||||
val full = BatteryEstimate(
|
||||
left = pod(minutes = 300, untilCharged = 25),
|
||||
right = pod(minutes = 280, untilCharged = null),
|
||||
headset = null,
|
||||
)
|
||||
|
||||
full.displayKey(chargingDevice(leftCharging = true, rightCharging = true)) shouldBe listOf(25, null, null)
|
||||
full.displayKey(chargingDevice()) shouldBe listOf(300, 280, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `display key is null when nothing would be rendered`() {
|
||||
// A charging pod without a usable ETA renders exactly like no estimate at all.
|
||||
val suppressed = BatteryEstimate(left = pod(minutes = 300, untilCharged = null))
|
||||
|
||||
suppressed.displayKey(chargingDevice(leftCharging = true)).shouldBeNull()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user