feat(settings): Group device settings into card containers

Wrap setting sections in SettingsSection cards using Material 3 surfaceContainerLow surfaces with rounded corners. Reorder Controls by usage frequency, move Microphone Mode to Other section, and replace ear detection info row with a contextual info box that only appears for BLE-only connections.
This commit is contained in:
darken
2026-04-14 11:28:41 +02:00
committed by Matthias Urhahn
parent 609423b27d
commit 65a74e9bc1
4 changed files with 362 additions and 258 deletions
@@ -9,18 +9,19 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Settings
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.material.icons.Icons
import androidx.compose.material.icons.twotone.Settings
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import eu.darken.capod.common.compose.Preview2
@@ -95,7 +96,10 @@ fun SettingsBaseItem(
text = title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = contentAlpha)
color = MaterialTheme.colorScheme.onSurface.copy(alpha = contentAlpha),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
if (requiresUpgrade) {
Spacer(modifier = Modifier.width(6.dp))
@@ -0,0 +1,57 @@
package eu.darken.capod.common.settings
import androidx.compose.foundation.layout.Row
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.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
@Composable
fun SettingsInfoBox(
text: String,
modifier: Modifier = Modifier,
) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f),
shape = RoundedCornerShape(12.dp),
modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.Top,
) {
Icon(
imageVector = Icons.Outlined.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(end = 10.dp, top = 2.dp)
.size(20.dp),
)
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
)
}
}
}
@Preview2
@Composable
private fun SettingsInfoBoxPreview() = PreviewWrapper {
SettingsInfoBox(
text = "If ear detection only works for one pod, this is an Apple limitation. Only the \"primary pod\" (used for microphone) is detected.",
)
}
@@ -0,0 +1,56 @@
package eu.darken.capod.common.settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Notifications
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
@Composable
fun SettingsSection(
modifier: Modifier = Modifier,
title: String? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerLow,
shape = RoundedCornerShape(16.dp),
modifier = modifier.padding(horizontal = 16.dp, vertical = 4.dp),
) {
Column {
if (title != null) {
SettingsCategoryHeader(text = title)
}
content()
}
}
}
@Preview2
@Composable
private fun SettingsSectionPreview() = PreviewWrapper {
SettingsSection(title = "Reactions") {
SettingsSwitchItem(
icon = Icons.TwoTone.Notifications,
title = "Auto Play",
subtitle = "Resume playback when AirPods are inserted",
checked = true,
onCheckedChange = {},
)
SettingsSwitchItem(
icon = Icons.TwoTone.Notifications,
title = "Auto Pause",
subtitle = "Pause when AirPods are removed",
checked = false,
onCheckedChange = {},
)
}
}
@@ -86,7 +86,9 @@ 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.common.settings.SettingsInfoBox
import eu.darken.capod.common.settings.SettingsPreferenceItem
import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.common.settings.SettingsSliderItem
import eu.darken.capod.common.settings.SettingsSwitchItem
import eu.darken.capod.monitor.core.PodDevice
@@ -282,12 +284,9 @@ fun DeviceSettingsScreen(
// ── Reactions (per-profile, not gated on AAP) ─────────────────
if (device != null && features != null) {
item("reactions_header") {
SettingsCategoryHeader(text = stringResource(R.string.settings_reaction_label))
}
item("reactions_section") {
SettingsSection(title = stringResource(R.string.settings_reaction_label)) {
if (features.hasEarDetection) {
item("reaction_auto_play") {
SettingsSwitchItem(
icon = Icons.TwoTone.PlayCircle,
title = stringResource(R.string.settings_autopplay_label),
@@ -296,8 +295,6 @@ fun DeviceSettingsScreen(
onCheckedChange = onAutoPlayChange,
requiresUpgrade = !isPro,
)
}
item("reaction_auto_pause") {
SettingsSwitchItem(
icon = Icons.TwoTone.PauseCircle,
title = stringResource(R.string.settings_autopause_label),
@@ -306,10 +303,8 @@ fun DeviceSettingsScreen(
onCheckedChange = onAutoPauseChange,
requiresUpgrade = !isPro,
)
}
if (features.hasDualPods) {
val earDetectionActive = reactions.autoPlay || reactions.autoPause
item("reaction_one_pod_mode") {
SettingsBaseItem(
title = stringResource(R.string.settings_onepod_mode_label),
subtitle = stringResource(R.string.settings_onepod_mode_description),
@@ -326,18 +321,14 @@ fun DeviceSettingsScreen(
},
)
}
}
item("reaction_ear_detection_info") {
SettingsBaseItem(
title = stringResource(R.string.settings_eardetection_info_label),
subtitle = stringResource(R.string.settings_eardetection_info_description),
icon = Icons.TwoTone.QuestionMark,
onClick = {},
enabled = false,
val earDetectionWarningVisible =
(reactions.autoPlay || reactions.autoPause) && !device.isAapConnected
if (earDetectionWarningVisible) {
SettingsInfoBox(
text = stringResource(R.string.settings_eardetection_info_description),
)
}
}
item("reaction_auto_connect") {
SettingsSwitchItem(
icon = Icons.TwoTone.BluetoothConnected,
title = stringResource(R.string.settings_autoconnect_label),
@@ -345,8 +336,6 @@ fun DeviceSettingsScreen(
checked = reactions.autoConnect,
onCheckedChange = onAutoConnectChange,
)
}
item("reaction_auto_connect_condition") {
SettingsBaseItem(
title = stringResource(R.string.settings_autoconnect_condition_label),
subtitle = stringResource(reactions.autoConnectCondition.labelRes),
@@ -354,9 +343,7 @@ fun DeviceSettingsScreen(
onClick = { if (reactions.autoConnect) showAutoConnectConditionDialog = true },
enabled = reactions.autoConnect,
)
}
if (features.hasCase) {
item("reaction_popup_caseopen") {
SettingsSwitchItem(
icon = Icons.AutoMirrored.TwoTone.Message,
title = stringResource(R.string.settings_popup_caseopen_label),
@@ -366,8 +353,6 @@ fun DeviceSettingsScreen(
requiresUpgrade = !isPro,
)
}
}
item("reaction_popup_connection") {
SettingsSwitchItem(
icon = Icons.AutoMirrored.TwoTone.Message,
title = stringResource(R.string.settings_popup_connected_label),
@@ -378,6 +363,7 @@ fun DeviceSettingsScreen(
)
}
}
}
// Settings — only show when AAP is connected
if (features != null && device.isAapConnected) {
@@ -461,14 +447,12 @@ fun DeviceSettingsScreen(
// ── Sound ────────────────────────────────────
val personalizedVol = device.personalizedVolume
val toneVol = device.toneVolume
if ((features.hasPersonalizedVolume && personalizedVol != null) || (features.hasToneVolume && toneVol != null)) {
item("sound_header") {
SettingsCategoryHeader(text = stringResource(R.string.device_settings_category_sound_label))
}
}
val showSoundSection =
(features.hasPersonalizedVolume && personalizedVol != null) || (features.hasToneVolume && toneVol != null)
if (showSoundSection) {
item("sound_section") {
SettingsSection(title = stringResource(R.string.device_settings_category_sound_label)) {
if (features.hasPersonalizedVolume && personalizedVol != null) {
item("personalized_volume") {
SettingsSwitchItem(
icon = Icons.AutoMirrored.TwoTone.VolumeUp,
title = stringResource(R.string.device_settings_personalized_volume_label),
@@ -478,10 +462,7 @@ fun DeviceSettingsScreen(
enabled = enabled,
)
}
}
if (features.hasToneVolume && toneVol != null) {
item("tone_volume") {
ToneVolumeSlider(
level = toneVol.level,
onLevelChange = onToneVolumeChange,
@@ -489,15 +470,42 @@ fun DeviceSettingsScreen(
)
}
}
// ── Controls ─────────────────────────────────
item("controls_header") {
SettingsCategoryHeader(text = stringResource(R.string.device_settings_category_controls_label))
}
}
// ── Controls ─────────────────────────────────
val pressSpd = device.pressSpeed
val pressHold = device.pressHoldDuration
val volSwipe = device.volumeSwipe
val volSwipeLen = device.volumeSwipeLength
val endCallMuteMic = device.endCallMuteMic
val showControlsSection = features.hasStemConfig ||
(features.hasEndCallMuteMic && endCallMuteMic != null) ||
(features.hasPressSpeed && pressSpd != null) ||
(features.hasPressHoldDuration && pressHold != null) ||
(features.hasVolumeSwipe && volSwipe != null) ||
(features.hasVolumeSwipeLength && volSwipeLen != null)
if (showControlsSection) {
item("controls_section") {
SettingsSection(title = stringResource(R.string.device_settings_category_controls_label)) {
if (features.hasStemConfig) {
SettingsPreferenceItem(
icon = Icons.TwoTone.TouchApp,
title = stringResource(R.string.stem_actions_title),
subtitle = stringResource(R.string.stem_actions_nav_description),
onClick = onStemActionsClick,
enabled = enabled,
requiresUpgrade = !isPro,
)
}
if (features.hasEndCallMuteMic && endCallMuteMic != null) {
EndCallMuteMicControl(
current = endCallMuteMic,
onChange = onEndCallMuteMicChange,
enabled = enabled,
)
}
if (features.hasPressSpeed && pressSpd != null) {
item("press_speed") {
SegmentedSettingRow(
icon = Icons.TwoTone.Speed,
title = stringResource(R.string.device_settings_press_speed_label),
@@ -512,11 +520,7 @@ fun DeviceSettingsScreen(
enabled = enabled,
)
}
}
val pressHold = device.pressHoldDuration
if (features.hasPressHoldDuration && pressHold != null) {
item("press_hold") {
SegmentedSettingRow(
icon = Icons.TwoTone.Timer,
title = stringResource(R.string.device_settings_press_hold_label),
@@ -531,11 +535,7 @@ fun DeviceSettingsScreen(
enabled = enabled,
)
}
}
val volSwipe = device.volumeSwipe
if (features.hasVolumeSwipe && volSwipe != null) {
item("volume_swipe") {
SettingsSwitchItem(
icon = Icons.TwoTone.Swipe,
title = stringResource(R.string.device_settings_volume_swipe_label),
@@ -545,11 +545,7 @@ fun DeviceSettingsScreen(
enabled = enabled,
)
}
}
val volSwipeLen = device.volumeSwipeLength
if (features.hasVolumeSwipeLength && volSwipeLen != null) {
item("volume_swipe_length") {
SegmentedSettingRow(
icon = Icons.TwoTone.Swipe,
title = stringResource(R.string.device_settings_volume_swipe_length_label),
@@ -565,22 +561,17 @@ fun DeviceSettingsScreen(
)
}
}
val endCallMuteMic = device.endCallMuteMic
if (features.hasEndCallMuteMic && endCallMuteMic != null) {
item("end_call_mute_mic") {
EndCallMuteMicControl(
current = endCallMuteMic,
onChange = onEndCallMuteMicChange,
enabled = enabled,
)
}
}
// ── Other ───────────────────────────────────
val showOtherSection = features.hasMicrophoneMode || features.hasSleepDetection
if (showOtherSection) {
item("other_section") {
SettingsSection(title = stringResource(R.string.settings_category_other_label)) {
if (features.hasMicrophoneMode) {
val micMode = device.microphoneMode
?: AapSetting.MicrophoneMode(AapSetting.MicrophoneMode.Mode.AUTO)
item("microphone_mode") {
SegmentedSettingRow(
icon = Icons.TwoTone.Mic,
title = stringResource(R.string.device_settings_microphone_mode_label),
@@ -595,30 +586,9 @@ fun DeviceSettingsScreen(
enabled = enabled,
)
}
}
if (features.hasStemConfig) {
item("stem_actions") {
SettingsPreferenceItem(
icon = Icons.TwoTone.TouchApp,
title = stringResource(R.string.stem_actions_title),
subtitle = stringResource(R.string.stem_actions_nav_description),
onClick = onStemActionsClick,
enabled = enabled,
requiresUpgrade = !isPro,
)
}
}
// ── General ──────────────────────────────────
item("general_header") {
SettingsCategoryHeader(text = stringResource(R.string.device_settings_category_general_label))
}
if (features.hasSleepDetection) {
val sleepDet = device.sleepDetection
?: AapSetting.SleepDetection(enabled = true)
item("sleep_detection") {
SettingsSwitchItem(
icon = Icons.TwoTone.Nightlight,
title = stringResource(R.string.device_settings_sleep_detection_label),
@@ -630,28 +600,27 @@ fun DeviceSettingsScreen(
)
}
}
}
}
// ── Connections ───────────────────────────────
val connectedDevices = device.connectedDevices
if (connectedDevices != null && connectedDevices.devices.isNotEmpty()) {
item("connections_header") {
SettingsCategoryHeader(text = stringResource(R.string.device_settings_category_connections_label))
}
item("connected_devices") {
item("connections_section") {
SettingsSection(title = stringResource(R.string.device_settings_category_connections_label)) {
ConnectedDevicesList(
devices = connectedDevices.devices,
audioSource = device.audioSource,
)
}
}
}
// EQ visualization (debug only)
val eqBands = device.eqBands
if (eu.darken.capod.BuildConfig.DEBUG && eqBands != null && eqBands.sets.isNotEmpty()) {
item("eq_header") {
SettingsCategoryHeader(text = stringResource(R.string.device_settings_eq_label))
}
item("eq_bars") {
item("eq_section") {
SettingsSection(title = stringResource(R.string.device_settings_eq_label)) {
EqBarsChart(
sets = eqBands.sets,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
@@ -659,6 +628,7 @@ fun DeviceSettingsScreen(
}
}
}
}
item("bottom_spacer") {
Spacer(modifier = Modifier.height(16.dp))
@@ -1423,6 +1393,23 @@ private fun DeviceSettingsInfoOnlyPreview() = PreviewWrapper {
)
}
@Preview2
@Composable
private fun DeviceSettingsLowFeaturePreview() = PreviewWrapper {
DeviceSettingsScreen(
state = DeviceSettingsViewModel.State(
device = PodDevice(
profileId = "preview-low-feature",
label = "Beats Solo 3",
ble = MockPodDataProvider.beatsSolo3(),
aap = null,
),
now = MOCK_NOW,
),
onNavigateUp = {},
)
}
@Preview2
@Composable
private fun DeviceSettingsCachedOnlyPreview() = PreviewWrapper {