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 91c44049..32919de6 100644 --- a/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt +++ b/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt @@ -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), diff --git a/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt b/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt index a0315a7c..18b8cfde 100644 --- a/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt +++ b/app/src/main/java/eu/darken/capod/common/compose/preview/MockPodDataProvider.kt @@ -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 --- diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt index 331a74df..c51b1acb 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt @@ -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) 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 b431b66e..20b32fce 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 @@ -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, + 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, val devices: List, val isDebug: Boolean, val isBluetoothEnabled: Boolean, + val effectiveMode: MonitorMode, val profiles: List, 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)) } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/BackgroundMonitoringOffCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/BackgroundMonitoringOffCard.kt new file mode 100644 index 00000000..1cf8d9e2 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/BackgroundMonitoringOffCard.kt @@ -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 = {}) +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/MissingPairedDeviceBanner.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/MissingPairedDeviceBanner.kt index b1774b08..ebdd5ada 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/MissingPairedDeviceBanner.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/MissingPairedDeviceBanner.kt @@ -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), + ) + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1d079823..ecb948dd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -225,6 +225,9 @@ Bluetooth is disabled, enable it ;) Monitoring for devices Make sure your device is nearby and active. + Background monitoring is off + 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. + Select paired device Connected, but no data 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. Run troubleshooter @@ -527,6 +530,7 @@ Double press to end call Open device settings This profile has no paired Bluetooth device. + Without one, device settings and auto-connect aren\'t available. Reactions are per device Auto-play, auto-pause and pop-ups are now configured per device. Tap the settings icon on a card while your headphones are connected.