mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user