mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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))
|
||||
}
|
||||
|
||||
+67
@@ -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 = {})
|
||||
}
|
||||
+14
-5
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user