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.
This commit is contained in:
darken
2026-04-14 11:28:41 +02:00
committed by Matthias Urhahn
parent 34c23751b3
commit c0060ce5ca
6 changed files with 122 additions and 12 deletions
@@ -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")
}
},
)
}
@@ -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)
@@ -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),
)
}
}
}
}
@@ -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<eu.darken.capod.common.bluetooth.BluetoothDevice2>
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)
@@ -52,7 +52,13 @@ class PopUpWindow @Inject constructor(
private var lifecycleOwner: OverlayLifecycleOwner? = null
private var deviceState: MutableState<PodDevice?>? = null
@Volatile var isMainActivityVisible: Boolean = false
fun show(device: PodDevice) {
if (isMainActivityVisible) {
log(TAG) { "Suppressing popup, MainActivity is visible" }
return
}
try {
log(TAG) { "open()" }
+3
View File
@@ -85,6 +85,9 @@
<string name="settings_popup_caseopen_description">Show a popup when the device case is opened (experimental).</string>
<string name="settings_popup_connected_label">Show connection popup</string>
<string name="settings_popup_connected_description">Show a popup when the device connects for the first time.</string>
<string name="settings_popup_info_not_in_app">Popups are only shown when you are not in the app.</string>
<string name="settings_popup_warning_manual_mode">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.</string>
<string name="general_fix_it_action">Fix it</string>
<string name="notification_channel_device_status_label">Device status</string>
<string name="notification_channel_device_status_connected_label">Connected device</string>