feat(device-settings): Add experimental info box, revise pro-gating, fix switch bypass

Add SettingsInfoBox title support and experimental feature warning for Sleep Detection and Personalized Volume toggles with issue tracker action.

Pro-gate tone volume, microphone mode. Un-gate sleep detection, conversation awareness. Fix SettingsSwitchItem allowing direct switch toggle to bypass requiresUpgrade.
This commit is contained in:
darken
2026-04-16 10:32:21 +02:00
committed by Matthias Urhahn
parent 6f90687b72
commit 90af17bf7d
6 changed files with 110 additions and 25 deletions
@@ -2,7 +2,9 @@ package eu.darken.capod.common.settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -17,6 +19,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
@@ -28,6 +31,7 @@ fun SettingsInfoBox(
text: String,
modifier: Modifier = Modifier,
type: InfoBoxType = InfoBoxType.INFO,
title: String? = null,
action: @Composable (() -> Unit)? = null,
) {
val containerColor = when (type) {
@@ -66,6 +70,16 @@ fun SettingsInfoBox(
modifier = Modifier.weight(1f),
horizontalAlignment = if (action != null) Alignment.End else Alignment.Start,
) {
if (title != null) {
Text(
text = title,
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f),
)
Spacer(modifier = Modifier.height(2.dp))
}
Text(
text = text,
modifier = Modifier.fillMaxWidth(),
@@ -110,3 +124,18 @@ private fun SettingsInfoBoxWarningWithActionPreview() = PreviewWrapper {
},
)
}
@Preview2
@Composable
private fun SettingsInfoBoxWarningWithTitlePreview() = PreviewWrapper {
SettingsInfoBox(
title = "Experimental feature",
text = "This feature hasn't been tested on all AirPods devices yet. If something isn't working correctly, please open an issue on GitHub with a debug log.",
type = InfoBoxType.WARNING,
action = {
TextButton(onClick = {}) {
Text("Open Issue Tracker")
}
},
)
}
@@ -33,7 +33,7 @@ fun SettingsSwitchItem(
trailingContent = {
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
onCheckedChange = if (requiresUpgrade) null else onCheckedChange,
enabled = enabled,
modifier = Modifier.padding(start = 16.dp)
)
@@ -175,6 +175,7 @@ fun DeviceSettingsScreenHost(
onShowPopUpOnCaseOpenChange = { vm.setShowPopUpOnCaseOpen(it) },
onShowPopUpOnConnectionChange = { vm.setShowPopUpOnConnection(it) },
onFixMonitorMode = { vm.setMonitorModeAutomatic() },
onOpenIssueTracker = { vm.openIssueTracker() },
)
}
@@ -211,6 +212,7 @@ fun DeviceSettingsScreen(
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
onFixMonitorMode: () -> Unit = {},
onOpenIssueTracker: () -> Unit = {},
) {
val device = state.device
val features = device?.model?.features
@@ -379,8 +381,19 @@ fun DeviceSettingsScreen(
checked = sleepDet.enabled,
onCheckedChange = onSleepDetectionChange,
enabled = enabled,
requiresUpgrade = !isPro,
)
if (sleepDet.enabled) {
SettingsInfoBox(
title = stringResource(R.string.device_settings_experimental_title),
text = stringResource(R.string.device_settings_experimental_description),
type = InfoBoxType.WARNING,
action = {
TextButton(onClick = onOpenIssueTracker) {
Text(stringResource(R.string.device_settings_experimental_action))
}
},
)
}
}
if (hasAnyAapReaction) {
ReactionsDivider()
@@ -516,30 +529,62 @@ fun DeviceSettingsScreen(
onCheckedChange = onPersonalizedVolumeChange,
enabled = enabled,
)
if (personalizedVol.enabled) {
SettingsInfoBox(
title = stringResource(R.string.device_settings_experimental_title),
text = stringResource(R.string.device_settings_experimental_description),
type = InfoBoxType.WARNING,
action = {
TextButton(onClick = onOpenIssueTracker) {
Text(stringResource(R.string.device_settings_experimental_action))
}
},
)
}
}
if (features.hasToneVolume && toneVol != null) {
ToneVolumeSlider(
level = toneVol.level,
onLevelChange = onToneVolumeChange,
enabled = enabled,
)
if (isPro) {
ToneVolumeSlider(
level = toneVol.level,
onLevelChange = onToneVolumeChange,
enabled = enabled,
)
} else {
SettingsBaseItem(
icon = Icons.AutoMirrored.TwoTone.VolumeUp,
title = stringResource(R.string.device_settings_tone_volume_label),
subtitle = stringResource(R.string.device_settings_tone_volume_description),
onClick = onUpgrade,
requiresUpgrade = true,
)
}
}
if (features.hasMicrophoneMode) {
val micMode = device.microphoneMode
?: AapSetting.MicrophoneMode(AapSetting.MicrophoneMode.Mode.AUTO)
SegmentedSettingRow(
icon = Icons.TwoTone.Mic,
title = stringResource(R.string.device_settings_microphone_mode_label),
subtitle = stringResource(R.string.device_settings_microphone_mode_description),
options = listOf(
stringResource(R.string.device_settings_microphone_mode_auto) to AapSetting.MicrophoneMode.Mode.AUTO,
stringResource(R.string.device_settings_microphone_mode_left) to AapSetting.MicrophoneMode.Mode.ALWAYS_LEFT,
stringResource(R.string.device_settings_microphone_mode_right) to AapSetting.MicrophoneMode.Mode.ALWAYS_RIGHT,
),
selected = micMode.mode,
onSelected = onMicrophoneModeChange,
enabled = enabled,
)
if (isPro) {
val micMode = device.microphoneMode
?: AapSetting.MicrophoneMode(AapSetting.MicrophoneMode.Mode.AUTO)
SegmentedSettingRow(
icon = Icons.TwoTone.Mic,
title = stringResource(R.string.device_settings_microphone_mode_label),
subtitle = stringResource(R.string.device_settings_microphone_mode_description),
options = listOf(
stringResource(R.string.device_settings_microphone_mode_auto) to AapSetting.MicrophoneMode.Mode.AUTO,
stringResource(R.string.device_settings_microphone_mode_left) to AapSetting.MicrophoneMode.Mode.ALWAYS_LEFT,
stringResource(R.string.device_settings_microphone_mode_right) to AapSetting.MicrophoneMode.Mode.ALWAYS_RIGHT,
),
selected = micMode.mode,
onSelected = onMicrophoneModeChange,
enabled = enabled,
)
} else {
SettingsBaseItem(
icon = Icons.TwoTone.Mic,
title = stringResource(R.string.device_settings_microphone_mode_label),
subtitle = stringResource(R.string.device_settings_microphone_mode_description),
onClick = onUpgrade,
requiresUpgrade = true,
)
}
}
}
}
@@ -1,6 +1,7 @@
package eu.darken.capod.main.ui.devicesettings
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.datastore.value
@@ -50,6 +51,7 @@ class DeviceSettingsViewModel @Inject constructor(
private val profilesRepo: DeviceProfilesRepo,
private val generalSettings: GeneralSettings,
private val timeSource: TimeSource,
private val webpageTool: WebpageTool,
) : ViewModel4(dispatcherProvider) {
private val targetProfileId = MutableStateFlow<ProfileId?>(null)
@@ -208,7 +210,7 @@ class DeviceSettingsViewModel @Inject constructor(
fun setPersonalizedVolume(enabled: Boolean) = send(AapCommand.SetPersonalizedVolume(enabled))
fun setToneVolume(level: Int) = send(AapCommand.SetToneVolume(level))
fun setToneVolume(level: Int) = sendProGated(AapCommand.SetToneVolume(level))
fun setAdaptiveAudioNoise(level: Int) = send(AapCommand.SetAdaptiveAudioNoise(level))
@@ -225,7 +227,7 @@ class DeviceSettingsViewModel @Inject constructor(
endCall: AapSetting.EndCallMuteMic.EndCallMode,
) = send(AapCommand.SetEndCallMuteMic(muteMic, endCall))
fun setMicrophoneMode(mode: AapSetting.MicrophoneMode.Mode) = send(AapCommand.SetMicrophoneMode(mode))
fun setMicrophoneMode(mode: AapSetting.MicrophoneMode.Mode) = sendProGated(AapCommand.SetMicrophoneMode(mode))
fun setEarDetectionEnabled(enabled: Boolean) = send(AapCommand.SetEarDetectionEnabled(enabled))
@@ -246,7 +248,7 @@ class DeviceSettingsViewModel @Inject constructor(
}
}
fun setSleepDetection(enabled: Boolean) = sendProGated(AapCommand.SetSleepDetection(enabled))
fun setSleepDetection(enabled: Boolean) = send(AapCommand.SetSleepDetection(enabled))
fun setDeviceName(name: String) = launch {
val address = currentAddress() ?: return@launch
@@ -395,6 +397,10 @@ class DeviceSettingsViewModel @Inject constructor(
navTo(Nav.Main.Upgrade)
}
fun openIssueTracker() {
webpageTool.open("https://github.com/d4rken-org/capod/issues")
}
companion object {
private val TAG = logTag("DeviceSettings", "VM")
}
+4
View File
@@ -90,6 +90,10 @@
<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="device_settings_experimental_title">Experimental feature</string>
<string name="device_settings_experimental_description">This feature hasn\'t been tested on all devices yet. If something isn\'t working correctly, please open an issue with a debug log.</string>
<string name="device_settings_experimental_action">Open Issue Tracker</string>
<string name="notification_channel_device_status_label">Device status</string>
<string name="notification_channel_device_status_connected_label">Connected device</string>
<string name="monitor_notification_extra_enabled_hint">You can disable this notification in system settings.</string>
@@ -120,6 +120,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
profilesRepo = profilesRepo,
generalSettings = generalSettings,
timeSource = timeSource,
webpageTool = mockk(relaxed = true),
).also { vm = it }
private fun runVmTest(testBody: suspend TestScope.() -> Unit) = runTest(testDispatcher) {