fix(overview): Signal when background monitoring is off

An auto-created profile without a paired Bluetooth device resolves to MANUAL
mode, so nothing runs in the background while the dashboard claimed to be
monitoring. The dashboard now states that background monitoring is off and
offers to pick a paired device, and the per-card banner names what a missing
paired device costs.

Closes #658
This commit is contained in:
darken
2026-08-02 14:51:43 +02:00
committed by Matthias Urhahn
parent 53b4150ff6
commit 8f0ec5a374
7 changed files with 165 additions and 8 deletions
@@ -19,6 +19,7 @@ import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MOCK_NOW
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.common.theming.CapodTheme
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.ui.overview.OverviewScreen
import eu.darken.capod.main.ui.overview.OverviewViewModel
import eu.darken.capod.monitor.core.battery.BatteryEstimate
@@ -85,6 +86,7 @@ internal fun DashboardContent(showAap: Boolean = false) = PreviewWrapper {
devices = devices,
isDebug = false,
isBluetoothEnabled = true,
effectiveMode = MonitorMode.AUTOMATIC,
profiles = listOf(
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX),
@@ -149,9 +149,14 @@ object MockPodDataProvider {
// --- Profile helpers ---
fun profile(label: String, model: PodModel): AppleDeviceProfile = AppleDeviceProfile(
fun profile(
label: String,
model: PodModel,
address: String? = "AA:BB:CC:DD:EE:FF",
): AppleDeviceProfile = AppleDeviceProfile(
label = label,
model = model,
address = address,
)
// --- Dual pod with Apple keys ---
@@ -53,6 +53,8 @@ import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.ui.overview.cards.BackgroundMonitoringOffCard
import eu.darken.capod.main.ui.overview.cards.BluetoothDisabledCard
import eu.darken.capod.main.ui.overview.cards.DeviceLimitUpgradeCard
import eu.darken.capod.main.ui.overview.cards.DualPodsCard
@@ -186,6 +188,9 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
onToggleDeviceExpansion = { device ->
device.profileId?.let { vm.toggleDeviceExpansion(it) }
},
onSetupPairedDevice = {
currentState.soleProfileId?.let { vm.goToEditProfile(it) } ?: vm.goToDeviceManager()
},
)
}
@@ -204,6 +209,7 @@ fun OverviewScreen(
onDeviceSettings: (PodDevice) -> Unit = {},
onEditProfile: (PodDevice) -> Unit = {},
onToggleDeviceExpansion: (PodDevice) -> Unit = {},
onSetupPairedDevice: () -> Unit = {},
) {
Scaffold(
topBar = {
@@ -308,6 +314,12 @@ fun OverviewScreen(
// 4. Profiled device cards (limited to 1 for free users)
if (!state.isScanBlocked && state.isBluetoothEnabled) {
if (state.monitoringStatus == OverviewViewModel.MonitoringStatus.BACKGROUND_OFF) {
item(key = "monitoring_off") {
BackgroundMonitoringOffCard(onSetupPairedDevice = onSetupPairedDevice)
}
}
if (state.showReactionsHint && state.visibleProfiledDevices.isNotEmpty()) {
item(key = "reactions_hint") {
ReactionsMovedHintCard()
@@ -362,7 +374,7 @@ fun OverviewScreen(
}
// 5. Monitoring active card
if (state.profiles.isNotEmpty() && state.profiledDevices.isEmpty() && !state.shouldShowUnmatchedSection) {
if (state.monitoringStatus == OverviewViewModel.MonitoringStatus.SEARCHING) {
item(key = "monitoring_active") {
MonitoringActiveCard()
}
@@ -453,6 +465,7 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
),
isDebug = false,
isBluetoothEnabled = true,
effectiveMode = MonitorMode.AUTOMATIC,
profiles = listOf(
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
MockPodDataProvider.profile("AirPods Max", PodModel.AIRPODS_MAX),
@@ -490,6 +503,7 @@ private fun OverviewScreenEmptyPreview() = PreviewWrapper {
devices = emptyList(),
isDebug = false,
isBluetoothEnabled = true,
effectiveMode = MonitorMode.AUTOMATIC,
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
upgradeInfo = MockPodDataProvider.fossInfo(),
showUnmatchedDevices = false,
@@ -513,6 +527,7 @@ private fun OverviewScreenNoProfilesPreview() = PreviewWrapper {
devices = emptyList(),
isDebug = false,
isBluetoothEnabled = true,
effectiveMode = MonitorMode.MANUAL,
profiles = emptyList(),
upgradeInfo = MockPodDataProvider.gplayInfo(),
showUnmatchedDevices = false,
@@ -536,6 +551,7 @@ private fun OverviewScreenBluetoothOffPreview() = PreviewWrapper {
devices = emptyList(),
isDebug = false,
isBluetoothEnabled = false,
effectiveMode = MonitorMode.AUTOMATIC,
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
upgradeInfo = MockPodDataProvider.fossInfo(isPro = true),
showUnmatchedDevices = false,
@@ -549,6 +565,32 @@ private fun OverviewScreenBluetoothOffPreview() = PreviewWrapper {
)
}
@Preview2
@Composable
private fun OverviewScreenMonitoringOffPreview() = PreviewWrapper {
OverviewScreen(
state = OverviewViewModel.State(
now = SystemTimeSource.now(),
permissions = emptySet(),
devices = emptyList(),
isDebug = false,
isBluetoothEnabled = true,
effectiveMode = MonitorMode.MANUAL,
profiles = listOf(
MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2, address = null),
),
upgradeInfo = MockPodDataProvider.fossInfo(),
showUnmatchedDevices = false,
),
onRequestPermission = {},
onBluetoothSettings = {},
onManageDevices = {},
onSettings = {},
onUpgrade = {},
onToggleUnmatched = {},
)
}
@Composable
private fun ToolbarTitle(upgradeInfo: UpgradeRepo.Info) {
val appName = stringResource(R.string.app_name)
@@ -33,6 +33,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.profiles.core.ProfileId
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
@@ -90,6 +91,7 @@ class OverviewViewModel @Inject constructor(
val hideUnmatchedDevices: Boolean,
val showTroubleshootSuggestion: Boolean,
val batteryEstimates: Map<String, BatteryEstimate>,
val effectiveMode: MonitorMode,
)
/**
@@ -126,12 +128,14 @@ class OverviewViewModel @Inject constructor(
generalSettings.hideUnmatchedDevices.flow,
troubleshootSuggestion,
batteryEstimator.estimates,
) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion, batteryEstimates ->
monitorModeResolver.effectiveMode,
) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion, batteryEstimates, effectiveMode ->
OverviewUiSettings(
reactionsHintDismissed = reactionsHintDismissed,
hideUnmatchedDevices = hideUnmatched,
showTroubleshootSuggestion = showTroubleshootSuggestion,
batteryEstimates = batteryEstimates,
effectiveMode = effectiveMode,
)
}
@@ -214,6 +218,7 @@ class OverviewViewModel @Inject constructor(
devices = devices,
isDebug = isDebug,
isBluetoothEnabled = isBluetoothEnabled,
effectiveMode = uiSettings.effectiveMode,
profiles = profiles,
upgradeInfo = upgradeInfo,
showUnmatchedDevices = showUnmatched,
@@ -227,12 +232,15 @@ class OverviewViewModel @Inject constructor(
enum class BluetoothIconState { HIDDEN, DISABLED, NEARBY, CONNECTED }
enum class MonitoringStatus { HIDDEN, SEARCHING, BACKGROUND_OFF }
data class State(
val now: Instant,
val permissions: Set<Permission>,
val devices: List<PodDevice>,
val isDebug: Boolean,
val isBluetoothEnabled: Boolean,
val effectiveMode: MonitorMode,
val profiles: List<DeviceProfile>,
val upgradeInfo: UpgradeRepo.Info,
val showUnmatchedDevices: Boolean,
@@ -290,6 +298,22 @@ class OverviewViewModel @Inject constructor(
else -> BluetoothIconState.HIDDEN
}
/**
* [MonitoringStatus.BACKGROUND_OFF] is deliberately not conditioned on the absence of device
* cards: an address-less wildcard profile can still match an unrecognised Apple payload, and
* the resulting card carries neither the missing-paired-device banner nor an edit action.
*/
val monitoringStatus: MonitoringStatus
get() = when {
isScanBlocked || !isBluetoothEnabled -> MonitoringStatus.HIDDEN
profiles.isEmpty() -> MonitoringStatus.HIDDEN
effectiveMode == MonitorMode.MANUAL -> MonitoringStatus.BACKGROUND_OFF
profiledDevices.isEmpty() && !shouldShowUnmatchedSection -> MonitoringStatus.SEARCHING
else -> MonitoringStatus.HIDDEN
}
val soleProfileId: ProfileId? get() = profiles.singleOrNull()?.id
fun isPinned(device: PodDevice, index: Int): Boolean =
device.isSystemConnected || index == 0
@@ -336,6 +360,10 @@ class OverviewViewModel @Inject constructor(
fun goToEditProfile(device: PodDevice) {
val profileId = device.profileId ?: return
goToEditProfile(profileId)
}
fun goToEditProfile(profileId: ProfileId) {
log(TAG, INFO) { "goToEditProfile(profileId=$profileId)" }
navTo(Nav.Main.DeviceProfileCreation(profileId = profileId))
}
@@ -0,0 +1,67 @@
package eu.darken.capod.main.ui.overview.cards
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.DevicesOther
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
@Composable
fun BackgroundMonitoringOffCard(onSetupPairedDevice: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
) {
Text(
text = stringResource(R.string.overview_monitoring_off_label),
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.overview_monitoring_off_description),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onSetupPairedDevice,
modifier = Modifier.align(Alignment.End),
) {
Icon(
imageVector = Icons.TwoTone.DevicesOther,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
)
Text(text = stringResource(R.string.overview_monitoring_off_action))
}
}
}
}
@Preview2
@Composable
private fun BackgroundMonitoringOffCardPreview() = PreviewWrapper {
BackgroundMonitoringOffCard(onSetupPairedDevice = {})
}
@@ -1,5 +1,6 @@
package eu.darken.capod.main.ui.overview.cards.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -46,12 +47,20 @@ fun MissingPairedDeviceBanner(
.padding(end = 10.dp)
.size(20.dp),
)
Text(
text = stringResource(R.string.overview_card_missing_paired_device),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f),
Column(
modifier = Modifier.weight(1f),
)
) {
Text(
text = stringResource(R.string.overview_card_missing_paired_device),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f),
)
Text(
text = stringResource(R.string.overview_card_missing_paired_device_consequence),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
}
}
}
}
+4
View File
@@ -225,6 +225,9 @@
<string name="overview_bluetooth_disabled_description">Bluetooth is disabled, enable it ;)</string>
<string name="overview_monitoring_active_label">Monitoring for devices</string>
<string name="overview_monitoring_active_description">Make sure your device is nearby and active.</string>
<string name="overview_monitoring_off_label">Background monitoring is off</string>
<string name="overview_monitoring_off_description">No profile has a paired Bluetooth device, so CAPod only updates while the app is open. Select one to enable background monitoring, the ongoing notification and reactions.</string>
<string name="overview_monitoring_off_action">Select paired device</string>
<string name="overview_troubleshoot_suggestion_label">Connected, but no data</string>
<string name="overview_troubleshoot_suggestion_description">Your device is connected, but CAPod isn\'t receiving any live data from it. Your phone may need a compatibility option — the troubleshooter can try to find one automatically.</string>
<string name="overview_troubleshoot_suggestion_action">Run troubleshooter</string>
@@ -527,6 +530,7 @@
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Double press to end call</string>
<string name="device_settings_open_cd">Open device settings</string>
<string name="overview_card_missing_paired_device">This profile has no paired Bluetooth device.</string>
<string name="overview_card_missing_paired_device_consequence">Without one, device settings and auto-connect aren\'t available.</string>
<string name="overview_reactions_hint_title">Reactions are per device</string>
<string name="overview_reactions_hint_body">Auto-play, auto-pause and pop-ups are now configured per device. Tap the settings icon on a card while your headphones are connected.</string>