From c0060ce5cacb2f97f0074d517748da7313e45563 Mon Sep 17 00:00:00 2001 From: darken Date: Mon, 13 Apr 2026 19:37:27 +0200 Subject: [PATCH] feat(popup): Suppress popup when app is visible and add settings hints Hide the overlay popup when MainActivity is in the foreground since the user can already see battery info in the app. Show an info card when popups are enabled explaining they only appear outside the app. Show a warning card with a Fix button when monitor mode is MANUAL. Refactor SettingsInfoBox into a reusable component with INFO/WARNING types and optional action slot. --- .../capod/common/settings/SettingsInfoBox.kt | 77 ++++++++++++++++--- .../eu/darken/capod/main/ui/MainActivity.kt | 13 ++++ .../ui/devicesettings/DeviceSettingsScreen.kt | 20 +++++ .../devicesettings/DeviceSettingsViewModel.kt | 15 +++- .../capod/reaction/ui/popup/PopUpWindow.kt | 6 ++ app/src/main/res/values/strings.xml | 3 + 6 files changed, 122 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/common/settings/SettingsInfoBox.kt b/app/src/main/java/eu/darken/capod/common/settings/SettingsInfoBox.kt index e91a7893..517885a4 100644 --- a/app/src/main/java/eu/darken/capod/common/settings/SettingsInfoBox.kt +++ b/app/src/main/java/eu/darken/capod/common/settings/SettingsInfoBox.kt @@ -1,15 +1,19 @@ package eu.darken.capod.common.settings +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Warning import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -17,33 +21,61 @@ import androidx.compose.ui.unit.dp import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.PreviewWrapper +enum class InfoBoxType { INFO, WARNING } + @Composable fun SettingsInfoBox( text: String, modifier: Modifier = Modifier, + type: InfoBoxType = InfoBoxType.INFO, + action: @Composable (() -> Unit)? = null, ) { + val containerColor = when (type) { + InfoBoxType.INFO -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) + InfoBoxType.WARNING -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.4f) + } + val iconTint = when (type) { + InfoBoxType.INFO -> MaterialTheme.colorScheme.primary + InfoBoxType.WARNING -> MaterialTheme.colorScheme.tertiary + } + val icon = when (type) { + InfoBoxType.INFO -> Icons.Outlined.Info + InfoBoxType.WARNING -> Icons.Outlined.Warning + } + Surface( - color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f), + color = containerColor, shape = RoundedCornerShape(12.dp), modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) { Row( - modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.Top, + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 16.dp, bottom = if (action != null) 8.dp else 16.dp), + verticalAlignment = if (action != null) Alignment.Top else Alignment.CenterVertically, ) { Icon( - imageVector = Icons.Outlined.Info, + imageVector = icon, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, + tint = iconTint, modifier = Modifier - .padding(end = 10.dp, top = 2.dp) + .padding(end = 10.dp) .size(20.dp), ) - Text( - text = text, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), - ) + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = if (action != null) Alignment.End else Alignment.Start, + ) { + Text( + text = text, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + ) + if (action != null) { + action() + } + } } } } @@ -55,3 +87,26 @@ private fun SettingsInfoBoxPreview() = PreviewWrapper { text = "If ear detection only works for one pod, this is an Apple limitation. Only the \"primary pod\" (used for microphone) is detected.", ) } + +@Preview2 +@Composable +private fun SettingsInfoBoxWarningPreview() = PreviewWrapper { + SettingsInfoBox( + text = "This feature requires the monitor to be running.", + type = InfoBoxType.WARNING, + ) +} + +@Preview2 +@Composable +private fun SettingsInfoBoxWarningWithActionPreview() = PreviewWrapper { + SettingsInfoBox( + text = "Popups require the monitor to be running. Your monitor mode is set to \"When app is open\", which stops the monitor when you leave the app.", + type = InfoBoxType.WARNING, + action = { + TextButton(onClick = {}) { + Text("Fix it") + } + }, + ) +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/MainActivity.kt b/app/src/main/java/eu/darken/capod/main/ui/MainActivity.kt index 741c54f3..8c53917d 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/MainActivity.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/MainActivity.kt @@ -32,6 +32,7 @@ import eu.darken.capod.common.uix.Activity2 import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.currentThemeState import eu.darken.capod.main.core.themeState +import eu.darken.capod.reaction.ui.popup.PopUpWindow import javax.inject.Inject import eu.darken.capod.common.datastore.valueBlocking @@ -41,6 +42,7 @@ class MainActivity : Activity2() { @Inject lateinit var navCtrl: NavigationController @Inject lateinit var navigationEntries: Set<@JvmSuppressWildcards NavigationEntry> @Inject lateinit var generalSettings: GeneralSettings + @Inject lateinit var popUpWindow: PopUpWindow override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -98,6 +100,17 @@ class MainActivity : Activity2() { } } + override fun onResume() { + super.onResume() + popUpWindow.isMainActivityVisible = true + popUpWindow.close() + } + + override fun onPause() { + popUpWindow.isMainActivityVisible = false + super.onPause() + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) consumeUpgradeExtra(intent) diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt index a3d1682b..35557ed6 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt @@ -86,6 +86,8 @@ import eu.darken.capod.common.error.ErrorEventHandler import eu.darken.capod.common.navigation.NavigationEventHandler import eu.darken.capod.common.settings.SettingsBaseItem import eu.darken.capod.common.settings.SettingsCategoryHeader +import eu.darken.capod.main.core.MonitorMode +import eu.darken.capod.common.settings.InfoBoxType import eu.darken.capod.common.settings.SettingsInfoBox import eu.darken.capod.common.settings.SettingsPreferenceItem import eu.darken.capod.common.settings.SettingsSection @@ -168,6 +170,7 @@ fun DeviceSettingsScreenHost( onAutoConnectConditionChange = { vm.setAutoConnectCondition(it) }, onShowPopUpOnCaseOpenChange = { vm.setShowPopUpOnCaseOpen(it) }, onShowPopUpOnConnectionChange = { vm.setShowPopUpOnConnection(it) }, + onFixMonitorMode = { vm.setMonitorModeAutomatic() }, ) } @@ -204,6 +207,7 @@ fun DeviceSettingsScreen( onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {}, onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {}, onShowPopUpOnConnectionChange: (Boolean) -> Unit = {}, + onFixMonitorMode: () -> Unit = {}, ) { val device = state.device val features = device?.model?.features @@ -377,6 +381,22 @@ fun DeviceSettingsScreen( onCheckedChange = onShowPopUpOnConnectionChange, requiresUpgrade = !isPro, ) + val anyPopupEnabled = reactions.showPopUpOnCaseOpen || reactions.showPopUpOnConnection + if (anyPopupEnabled && state.monitorMode == MonitorMode.MANUAL) { + SettingsInfoBox( + text = stringResource(R.string.settings_popup_warning_manual_mode), + type = InfoBoxType.WARNING, + action = { + TextButton(onClick = onFixMonitorMode) { + Text(stringResource(R.string.general_fix_it_action)) + } + }, + ) + } else if (anyPopupEnabled) { + SettingsInfoBox( + text = stringResource(R.string.settings_popup_info_not_in_app), + ) + } } } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt index 1adc2282..c5f9f47b 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt @@ -87,7 +87,14 @@ class DeviceSettingsViewModel @Inject constructor( // The Bluetooth HEADSET profile lookup can be slow to produce its first value. // Seed this branch so the screen can render immediately after navigation. bluetoothManager.connectedDevices.onStart { emit(emptyList()) }, - ) { _, device, upgrade, forcing, connectedDevices -> + generalSettings.monitorMode.flow, + ) { args -> + val device = args[1] as PodDevice? + val upgrade = args[2] as eu.darken.capod.common.upgrade.UpgradeRepo.Info + val forcing = args[3] as Boolean + @Suppress("UNCHECKED_CAST") + val connectedDevices = args[4] as Collection + val monitorMode = args[5] as MonitorMode val connectedAddresses = connectedDevices.map { it.address }.toSet() State( device = device, @@ -96,6 +103,7 @@ class DeviceSettingsViewModel @Inject constructor( isNudgeAvailable = bluetoothManager.isNudgeAvailable, isForceConnecting = forcing, isClassicallyConnected = device?.address?.let { it in connectedAddresses } == true, + monitorMode = monitorMode, ) } }.asLiveState() @@ -118,6 +126,7 @@ class DeviceSettingsViewModel @Inject constructor( val isNudgeAvailable: Boolean = true, val isForceConnecting: Boolean = false, val isClassicallyConnected: Boolean = false, + val monitorMode: MonitorMode = MonitorMode.AUTOMATIC, ) { val reactions: ReactionConfig get() = device?.reactions ?: ReactionConfig() } @@ -316,6 +325,10 @@ class DeviceSettingsViewModel @Inject constructor( fun setShowPopUpOnConnection(enabled: Boolean) = proGatedReaction(enabled) { it.copy(showPopUpOnConnection = enabled) } + fun setMonitorModeAutomatic() = launch { + generalSettings.monitorMode.value(MonitorMode.AUTOMATIC) + } + fun navToStemConfig() = launch { if (upgradeRepo.isPro()) { navTo(Nav.Main.StemActionConfig) diff --git a/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpWindow.kt b/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpWindow.kt index 80ed16db..72faf30a 100644 --- a/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpWindow.kt +++ b/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpWindow.kt @@ -52,7 +52,13 @@ class PopUpWindow @Inject constructor( private var lifecycleOwner: OverlayLifecycleOwner? = null private var deviceState: MutableState? = null + @Volatile var isMainActivityVisible: Boolean = false + fun show(device: PodDevice) { + if (isMainActivityVisible) { + log(TAG) { "Suppressing popup, MainActivity is visible" } + return + } try { log(TAG) { "open()" } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f5bc27dc..e4101b29 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -85,6 +85,9 @@ Show a popup when the device case is opened (experimental). Show connection popup Show a popup when the device connects for the first time. + Popups are only shown when you are not in the app. + Popups require the monitor to be running. Your monitor mode is set to \"When app is open\", which stops the monitor when you leave the app. + Fix it Device status Connected device