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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -17,6 +19,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper import eu.darken.capod.common.compose.PreviewWrapper
@@ -28,6 +31,7 @@ fun SettingsInfoBox(
text: String, text: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
type: InfoBoxType = InfoBoxType.INFO, type: InfoBoxType = InfoBoxType.INFO,
title: String? = null,
action: @Composable (() -> Unit)? = null, action: @Composable (() -> Unit)? = null,
) { ) {
val containerColor = when (type) { val containerColor = when (type) {
@@ -66,6 +70,16 @@ fun SettingsInfoBox(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
horizontalAlignment = if (action != null) Alignment.End else Alignment.Start, 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 = text, text = text,
modifier = Modifier.fillMaxWidth(), 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 = { trailingContent = {
Switch( Switch(
checked = checked, checked = checked,
onCheckedChange = onCheckedChange, onCheckedChange = if (requiresUpgrade) null else onCheckedChange,
enabled = enabled, enabled = enabled,
modifier = Modifier.padding(start = 16.dp) modifier = Modifier.padding(start = 16.dp)
) )
@@ -175,6 +175,7 @@ fun DeviceSettingsScreenHost(
onShowPopUpOnCaseOpenChange = { vm.setShowPopUpOnCaseOpen(it) }, onShowPopUpOnCaseOpenChange = { vm.setShowPopUpOnCaseOpen(it) },
onShowPopUpOnConnectionChange = { vm.setShowPopUpOnConnection(it) }, onShowPopUpOnConnectionChange = { vm.setShowPopUpOnConnection(it) },
onFixMonitorMode = { vm.setMonitorModeAutomatic() }, onFixMonitorMode = { vm.setMonitorModeAutomatic() },
onOpenIssueTracker = { vm.openIssueTracker() },
) )
} }
@@ -211,6 +212,7 @@ fun DeviceSettingsScreen(
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {}, onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {}, onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
onFixMonitorMode: () -> Unit = {}, onFixMonitorMode: () -> Unit = {},
onOpenIssueTracker: () -> Unit = {},
) { ) {
val device = state.device val device = state.device
val features = device?.model?.features val features = device?.model?.features
@@ -379,8 +381,19 @@ fun DeviceSettingsScreen(
checked = sleepDet.enabled, checked = sleepDet.enabled,
onCheckedChange = onSleepDetectionChange, onCheckedChange = onSleepDetectionChange,
enabled = enabled, 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) { if (hasAnyAapReaction) {
ReactionsDivider() ReactionsDivider()
@@ -516,30 +529,62 @@ fun DeviceSettingsScreen(
onCheckedChange = onPersonalizedVolumeChange, onCheckedChange = onPersonalizedVolumeChange,
enabled = enabled, 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) { if (features.hasToneVolume && toneVol != null) {
ToneVolumeSlider( if (isPro) {
level = toneVol.level, ToneVolumeSlider(
onLevelChange = onToneVolumeChange, level = toneVol.level,
enabled = enabled, 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) { if (features.hasMicrophoneMode) {
val micMode = device.microphoneMode if (isPro) {
?: AapSetting.MicrophoneMode(AapSetting.MicrophoneMode.Mode.AUTO) val micMode = device.microphoneMode
SegmentedSettingRow( ?: AapSetting.MicrophoneMode(AapSetting.MicrophoneMode.Mode.AUTO)
icon = Icons.TwoTone.Mic, SegmentedSettingRow(
title = stringResource(R.string.device_settings_microphone_mode_label), icon = Icons.TwoTone.Mic,
subtitle = stringResource(R.string.device_settings_microphone_mode_description), title = stringResource(R.string.device_settings_microphone_mode_label),
options = listOf( subtitle = stringResource(R.string.device_settings_microphone_mode_description),
stringResource(R.string.device_settings_microphone_mode_auto) to AapSetting.MicrophoneMode.Mode.AUTO, options = listOf(
stringResource(R.string.device_settings_microphone_mode_left) to AapSetting.MicrophoneMode.Mode.ALWAYS_LEFT, stringResource(R.string.device_settings_microphone_mode_auto) to AapSetting.MicrophoneMode.Mode.AUTO,
stringResource(R.string.device_settings_microphone_mode_right) to AapSetting.MicrophoneMode.Mode.ALWAYS_RIGHT, 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, selected = micMode.mode,
enabled = enabled, 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 package eu.darken.capod.main.ui.devicesettings
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.bluetooth.BluetoothManager2 import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.DispatcherProvider import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.datastore.value import eu.darken.capod.common.datastore.value
@@ -50,6 +51,7 @@ class DeviceSettingsViewModel @Inject constructor(
private val profilesRepo: DeviceProfilesRepo, private val profilesRepo: DeviceProfilesRepo,
private val generalSettings: GeneralSettings, private val generalSettings: GeneralSettings,
private val timeSource: TimeSource, private val timeSource: TimeSource,
private val webpageTool: WebpageTool,
) : ViewModel4(dispatcherProvider) { ) : ViewModel4(dispatcherProvider) {
private val targetProfileId = MutableStateFlow<ProfileId?>(null) private val targetProfileId = MutableStateFlow<ProfileId?>(null)
@@ -208,7 +210,7 @@ class DeviceSettingsViewModel @Inject constructor(
fun setPersonalizedVolume(enabled: Boolean) = send(AapCommand.SetPersonalizedVolume(enabled)) 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)) fun setAdaptiveAudioNoise(level: Int) = send(AapCommand.SetAdaptiveAudioNoise(level))
@@ -225,7 +227,7 @@ class DeviceSettingsViewModel @Inject constructor(
endCall: AapSetting.EndCallMuteMic.EndCallMode, endCall: AapSetting.EndCallMuteMic.EndCallMode,
) = send(AapCommand.SetEndCallMuteMic(muteMic, endCall)) ) = 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)) 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 { fun setDeviceName(name: String) = launch {
val address = currentAddress() ?: return@launch val address = currentAddress() ?: return@launch
@@ -395,6 +397,10 @@ class DeviceSettingsViewModel @Inject constructor(
navTo(Nav.Main.Upgrade) navTo(Nav.Main.Upgrade)
} }
fun openIssueTracker() {
webpageTool.open("https://github.com/d4rken-org/capod/issues")
}
companion object { companion object {
private val TAG = logTag("DeviceSettings", "VM") 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="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="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_label">Device status</string>
<string name="notification_channel_device_status_connected_label">Connected device</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> <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, profilesRepo = profilesRepo,
generalSettings = generalSettings, generalSettings = generalSettings,
timeSource = timeSource, timeSource = timeSource,
webpageTool = mockk(relaxed = true),
).also { vm = it } ).also { vm = it }
private fun runVmTest(testBody: suspend TestScope.() -> Unit) = runTest(testDispatcher) { private fun runVmTest(testBody: suspend TestScope.() -> Unit) = runTest(testDispatcher) {