From 52a7850c1d6165a3721192b566a39094f68e8be0 Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 2 Jul 2026 15:41:38 +0200 Subject: [PATCH] feat(battery): Show battery estimates in widget and expanded notification --- .../capod/screenshots/ScreenshotContent.kt | 4 +- app/src/main/java/eu/darken/capod/App.kt | 16 +- .../main/ui/overview/OverviewViewModel.kt | 8 +- .../main/ui/widget/BatteryGlanceWidget.kt | 16 +- .../main/ui/widget/ComposeWidgetPreview.kt | 76 +++++---- .../main/ui/widget/GlanceWidgetContent.kt | 69 +++++--- .../ui/widget/WidgetConfigurationScreen.kt | 2 +- .../capod/main/ui/widget/WidgetDeviceKey.kt | 2 + .../capod/main/ui/widget/WidgetRenderState.kt | 17 ++ .../main/ui/widget/WidgetRenderStateMapper.kt | 10 ++ .../core/battery/BatteryEstimateExtensions.kt | 40 +++++ .../monitor/core/worker/MonitorService.kt | 37 ++++- .../ui/MonitorNotificationViewFactory.kt | 34 +++- .../capod/monitor/ui/MonitorNotifications.kt | 17 +- .../monitor_notification_dual_pods_big.xml | 4 +- .../monitor_notification_single_pods_big.xml | 2 +- .../main/ui/widget/WidgetDeviceKeyTest.kt | 10 ++ .../battery/BatteryEstimateExtensionsTest.kt | 154 ++++++++++++++++++ 18 files changed, 430 insertions(+), 88 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimateExtensions.kt create mode 100644 app/src/test/java/eu/darken/capod/monitor/core/battery/BatteryEstimateExtensionsTest.kt diff --git a/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt b/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt index fbe97496..21665a6c 100644 --- a/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt +++ b/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt @@ -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), ) } } diff --git a/app/src/main/java/eu/darken/capod/App.kt b/app/src/main/java/eu/darken/capod/App.kt index ac652e88..56846ff3 100644 --- a/app/src/main/java/eu/darken/capod/App.kt +++ b/app/src/main/java/eu/darken/capod/App.kt @@ -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." } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt index 843b57b3..47bcc0e2 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt @@ -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 by lazy { diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/BatteryGlanceWidget.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/BatteryGlanceWidget.kt index 40dfb4b9..3020fe03 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/BatteryGlanceWidget.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/BatteryGlanceWidget.kt @@ -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(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) diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/ComposeWidgetPreview.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/ComposeWidgetPreview.kt index b9e59276..6a8f8020 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/ComposeWidgetPreview.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/ComposeWidgetPreview.kt @@ -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 { diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/GlanceWidgetContent.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/GlanceWidgetContent.kt index 9f1e212d..e588ffad 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/GlanceWidgetContent.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/GlanceWidgetContent.kt @@ -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" } ?: "" diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationScreen.kt index c49e6748..94611602 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetConfigurationScreen.kt @@ -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) } diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetDeviceKey.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetDeviceKey.kt index f47d7969..4e3b1781 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetDeviceKey.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetDeviceKey.kt @@ -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, diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderState.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderState.kt index c4f76ee3..61b9afdb 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderState.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderState.kt @@ -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), +) diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderStateMapper.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderStateMapper.kt index dfb9718c..349ab34a 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderStateMapper.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetRenderStateMapper.kt @@ -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 diff --git a/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimateExtensions.kt b/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimateExtensions.kt new file mode 100644 index 00000000..8060607e --- /dev/null +++ b/app/src/main/java/eu/darken/capod/monitor/core/battery/BatteryEstimateExtensions.kt @@ -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.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? = listOf( + left?.displayMinutes(device.isLeftPodCharging == true), + right?.displayMinutes(device.isRightPodCharging == true), + headset?.displayMinutes(device.isHeadsetBeingCharged == true), +).takeUnless { key -> key.all { it == null } } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt index 2cf06a6e..13707672 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt @@ -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, diff --git a/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotificationViewFactory.kt b/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotificationViewFactory.kt index c407e8ac..812d3ed3 100644 --- a/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotificationViewFactory.kt +++ b/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotificationViewFactory.kt @@ -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)}" } ?: "" + } diff --git a/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotifications.kt b/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotifications.kt index 9a2a5198..c3d1cfb5 100644 --- a/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotifications.kt +++ b/app/src/main/java/eu/darken/capod/monitor/ui/MonitorNotifications.kt @@ -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() diff --git a/app/src/main/res/layout/monitor_notification_dual_pods_big.xml b/app/src/main/res/layout/monitor_notification_dual_pods_big.xml index b56f93de..f25ead87 100644 --- a/app/src/main/res/layout/monitor_notification_dual_pods_big.xml +++ b/app/src/main/res/layout/monitor_notification_dual_pods_big.xml @@ -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" /> + tools:text="92% · 5h 25m" /> + tools:text="95% · 14h 30m" /> ().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() + } +}