refactor(device-settings): Extract section cards into own files

This commit is contained in:
darken
2026-04-17 13:02:14 +02:00
committed by Matthias Urhahn
parent 5b187c6d5b
commit 07b3b95270
11 changed files with 1172 additions and 771 deletions
@@ -1,68 +1,33 @@
package eu.darken.capod.main.ui.devicesettings
import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
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.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
import androidx.compose.material.icons.automirrored.twotone.Message
import androidx.compose.material.icons.automirrored.twotone.VolumeUp
import androidx.compose.material.icons.twotone.BluetoothConnected
import androidx.compose.material.icons.twotone.GraphicEq
import androidx.compose.material.icons.twotone.Headphones
import androidx.compose.material.icons.twotone.Hearing
import androidx.compose.material.icons.twotone.LooksOne
import androidx.compose.material.icons.twotone.Loop
import androidx.compose.material.icons.twotone.Mic
import androidx.compose.material.icons.twotone.Nightlight
import androidx.compose.material.icons.twotone.PauseCircle
import androidx.compose.material.icons.twotone.PlayCircle
import androidx.compose.material.icons.twotone.Swipe
import androidx.compose.material.icons.twotone.TouchApp
import androidx.compose.material.icons.twotone.Workspaces
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -74,35 +39,23 @@ import eu.darken.capod.common.compose.preview.MOCK_NOW
import eu.darken.capod.common.compose.preview.MockPodDataProvider
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.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
import eu.darken.capod.common.settings.SettingsSliderItem
import eu.darken.capod.common.settings.SettingsSwitchItem
import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard
import eu.darken.capod.main.ui.devicesettings.cards.ControlsCard
import eu.darken.capod.main.ui.devicesettings.cards.DeviceDetailItem
import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard
import eu.darken.capod.main.ui.devicesettings.cards.buildModelLabel
import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard
import eu.darken.capod.main.ui.devicesettings.cards.NotConnectedCard
import eu.darken.capod.main.ui.devicesettings.components.CallControlSettings
import eu.darken.capod.main.ui.devicesettings.cards.ReactionsCard
import eu.darken.capod.main.ui.devicesettings.cards.SoundCard
import eu.darken.capod.main.ui.devicesettings.cards.buildModelLabel
import eu.darken.capod.main.ui.devicesettings.components.ConnectedDevicesList
import eu.darken.capod.main.ui.devicesettings.components.EqBarsChart
import eu.darken.capod.main.ui.devicesettings.components.PressHoldDurationSetting
import eu.darken.capod.main.ui.devicesettings.components.PressSpeedSetting
import eu.darken.capod.main.ui.devicesettings.components.VolumeSwipeLengthSetting
import eu.darken.capod.main.ui.components.icon
import eu.darken.capod.main.ui.components.shortLabel
import eu.darken.capod.main.ui.overview.cards.components.AncModeSelector
import eu.darken.capod.main.ui.devicesettings.dialogs.AutoConnectConditionDialog
import eu.darken.capod.main.ui.devicesettings.dialogs.SystemRenameUnavailableDialog
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.firstSeenFormatted
import eu.darken.capod.monitor.core.lastSeenFormatted
import eu.darken.capod.monitor.core.resolvedAncCycleMask
import eu.darken.capod.monitor.core.visibleAncModes
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
@@ -126,7 +79,6 @@ fun DeviceSettingsScreenHost(
var showListeningModeCycleDialog by rememberSaveable { mutableStateOf(false) }
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
val offRejectedMessage = stringResource(R.string.device_settings_anc_off_rejected_message)
val offRejectedAction = stringResource(R.string.device_settings_anc_off_rejected_action)
LaunchedEffect(Unit) {
vm.events.collect { event ->
@@ -143,15 +95,7 @@ fun DeviceSettingsScreenHost(
showRenameUnavailableDialog = true
}
DeviceSettingsViewModel.Event.OffModeRejectedByDevice -> {
val isPro = state?.isPro == true
val result = snackbarHostState.showSnackbar(
message = offRejectedMessage,
actionLabel = if (isPro) offRejectedAction else null,
withDismissAction = true,
)
if (result == SnackbarResult.ActionPerformed && isPro) {
showListeningModeCycleDialog = true
}
snackbarHostState.showSnackbar(offRejectedMessage)
}
}
}
@@ -188,7 +132,7 @@ fun DeviceSettingsScreenHost(
onEndCallMuteMicChange = { muteMic, endCall -> vm.setEndCallMuteMic(muteMic, endCall) },
onMicrophoneModeChange = { vm.setMicrophoneMode(it) },
onListeningModeCycleChange = { vm.setListeningModeCycle(it) },
onOffVisibilityChange = { enabled, mask -> vm.setListeningModeOffVisibility(enabled, mask) },
onAllowOffOptionChange = { vm.setAllowOffOption(it) },
onSleepDetectionChange = { vm.setSleepDetection(it) },
onDeviceNameChange = { vm.setDeviceName(it) },
onStemActionsClick = { vm.navToStemConfig() },
@@ -227,7 +171,7 @@ fun DeviceSettingsScreen(
onEndCallMuteMicChange: (AapSetting.EndCallMuteMic.MuteMicMode, AapSetting.EndCallMuteMic.EndCallMode) -> Unit = { _, _ -> },
onMicrophoneModeChange: (AapSetting.MicrophoneMode.Mode) -> Unit = {},
onListeningModeCycleChange: (Int) -> Unit = {},
onOffVisibilityChange: (enabled: Boolean, currentCycleMask: Int) -> Unit = { _, _ -> },
onAllowOffOptionChange: (Boolean) -> Unit = {},
onSleepDetectionChange: (Boolean) -> Unit = {},
onDeviceNameChange: (String) -> Unit = {},
onStemActionsClick: () -> Unit = {},
@@ -243,14 +187,10 @@ fun DeviceSettingsScreen(
onFixMonitorMode: () -> Unit = {},
onOpenIssueTracker: () -> Unit = {},
) {
val context = LocalContext.current
val device = state.device
val features = device?.model?.features
val enabled = device?.isAapReady == true
val isPro = state.isPro
val reactions = state.reactions
var showAutoConnectConditionDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
@@ -372,156 +312,23 @@ fun DeviceSettingsScreen(
// ── Reactions (per-profile, not gated on AAP) ─────────────────
if (device != null && features != null) {
item("reactions_section") {
SettingsSection(title = stringResource(R.string.settings_reaction_label)) {
if (features.hasEarDetection) {
SettingsSwitchItem(
icon = Icons.TwoTone.PlayCircle,
title = stringResource(R.string.settings_autopplay_label),
subtitle = stringResource(R.string.settings_autoplay_description),
checked = reactions.autoPlay,
onCheckedChange = onAutoPlayChange,
requiresUpgrade = !isPro,
)
SettingsSwitchItem(
icon = Icons.TwoTone.PauseCircle,
title = stringResource(R.string.settings_autopause_label),
subtitle = stringResource(R.string.settings_autopause_description),
checked = reactions.autoPause,
onCheckedChange = onAutoPauseChange,
requiresUpgrade = !isPro,
)
if (features.hasDualPods) {
val onePodModeActive = reactions.onePodMode ||
reactions.autoPlay ||
reactions.autoPause ||
(reactions.autoConnect && reactions.autoConnectCondition == AutoConnectCondition.IN_EAR)
SettingsBaseItem(
title = stringResource(R.string.settings_onepod_mode_label),
subtitle = stringResource(R.string.settings_onepod_mode_description),
icon = Icons.TwoTone.LooksOne,
onClick = { if (onePodModeActive) onOnePodModeChange(!reactions.onePodMode) },
enabled = onePodModeActive,
trailingContent = {
Switch(
checked = reactions.onePodMode,
onCheckedChange = onOnePodModeChange,
enabled = onePodModeActive,
modifier = Modifier.padding(start = 16.dp),
)
},
)
}
val earDetectionWarningVisible =
(
reactions.autoPlay ||
reactions.autoPause ||
(reactions.autoConnect && reactions.autoConnectCondition == AutoConnectCondition.IN_EAR)
) && !device.isAapConnected
if (earDetectionWarningVisible) {
SettingsInfoBox(
text = stringResource(R.string.settings_eardetection_info_description),
)
}
ReactionsDivider()
}
if (device.isAapConnected) {
val convAwareness = device.conversationalAwareness
val hasAnyAapReaction =
(features.hasConversationAwareness && convAwareness != null) ||
features.hasSleepDetection
if (features.hasConversationAwareness && convAwareness != null) {
SettingsSwitchItem(
icon = Icons.TwoTone.Hearing,
title = stringResource(R.string.conversation_awareness_label),
subtitle = stringResource(R.string.device_settings_conversation_awareness_description),
checked = convAwareness.enabled,
onCheckedChange = onConversationalAwarenessChange,
enabled = enabled,
)
}
if (features.hasSleepDetection) {
val sleepDet = device.sleepDetection
?: AapSetting.SleepDetection(enabled = true)
SettingsSwitchItem(
icon = Icons.TwoTone.Nightlight,
title = stringResource(R.string.device_settings_sleep_detection_label),
subtitle = stringResource(R.string.device_settings_sleep_detection_description),
checked = sleepDet.enabled,
onCheckedChange = onSleepDetectionChange,
enabled = enabled,
)
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()
}
}
SettingsSwitchItem(
icon = Icons.TwoTone.BluetoothConnected,
title = stringResource(R.string.settings_autoconnect_label),
subtitle = stringResource(R.string.settings_autoconnect_description),
checked = reactions.autoConnect,
onCheckedChange = onAutoConnectChange,
)
SettingsBaseItem(
title = stringResource(R.string.settings_autoconnect_condition_label),
subtitle = stringResource(reactions.autoConnectCondition.labelRes),
icon = Icons.TwoTone.Workspaces,
onClick = { if (reactions.autoConnect) showAutoConnectConditionDialog = true },
enabled = reactions.autoConnect,
)
if (reactions.autoConnect && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
SettingsInfoBox(
text = stringResource(R.string.settings_autoconnect_info_android12),
)
}
ReactionsDivider()
if (features.hasCase) {
SettingsSwitchItem(
icon = Icons.AutoMirrored.TwoTone.Message,
title = stringResource(R.string.settings_popup_caseopen_label),
subtitle = stringResource(R.string.settings_popup_caseopen_description),
checked = reactions.showPopUpOnCaseOpen,
onCheckedChange = onShowPopUpOnCaseOpenChange,
requiresUpgrade = !isPro,
)
}
SettingsSwitchItem(
icon = Icons.AutoMirrored.TwoTone.Message,
title = stringResource(R.string.settings_popup_connected_label),
subtitle = stringResource(R.string.settings_popup_connected_description),
checked = reactions.showPopUpOnConnection,
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),
)
}
}
ReactionsCard(
device = device,
features = features,
isPro = isPro,
monitorMode = state.monitorMode,
onAutoPlayChange = onAutoPlayChange,
onAutoPauseChange = onAutoPauseChange,
onOnePodModeChange = onOnePodModeChange,
onConversationalAwarenessChange = onConversationalAwarenessChange,
onSleepDetectionChange = onSleepDetectionChange,
onAutoConnectChange = onAutoConnectChange,
onAutoConnectConditionChange = onAutoConnectConditionChange,
onShowPopUpOnCaseOpenChange = onShowPopUpOnCaseOpenChange,
onShowPopUpOnConnectionChange = onShowPopUpOnConnectionChange,
onFixMonitorMode = onFixMonitorMode,
onOpenIssueTracker = onOpenIssueTracker,
)
}
}
@@ -537,103 +344,24 @@ fun DeviceSettingsScreen(
}
// ── Noise Control ────────────────────────────
val ancMode = device.ancMode
val adaptiveNoise = device.adaptiveAudioNoise
if (features.hasAncControl && ancMode != null) {
val cycleMask = device.resolvedAncCycleMask
val cycleSummary = if (cycleMask != null) listeningModeCycleSummary(context, ancMode.supported, cycleMask) else null
val cycleSubtitle = if (cycleSummary != null) {
buildString {
append(cycleSummary)
append('\n')
append(
context.getString(
if (state.hasCustomLongPressStemAction) {
R.string.device_settings_listening_mode_cycle_summary_helper_override
} else {
R.string.device_settings_listening_mode_cycle_summary_helper
}
),
)
}
} else {
null
}
if (features.hasAncControl && device.ancMode != null) {
item("noise_control_section") {
SettingsSection(title = stringResource(R.string.device_settings_noise_control_label)) {
NoiseControlCurrentModeControl(
currentMode = ancMode.current,
pendingMode = device.pendingAncMode,
supportedModes = device.visibleAncModes,
onModeSelected = onAncModeChange,
enabled = enabled,
)
val ncWithOneAirPod = device.ncWithOneAirPod
val showNcWithOneAirPod = features.hasNcOneAirpod && ncWithOneAirPod != null
val hasNoiseExtras = (features.hasAdaptiveAudioNoise && adaptiveNoise != null) ||
features.hasListeningModeCycle ||
showNcWithOneAirPod
if (hasNoiseExtras) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
}
if (showNcWithOneAirPod) {
SettingsSwitchItem(
icon = Icons.TwoTone.LooksOne,
title = stringResource(R.string.device_settings_nc_one_airpod_label),
subtitle = stringResource(R.string.device_settings_nc_one_airpod_description),
checked = ncWithOneAirPod!!.enabled,
onCheckedChange = onNcWithOneAirPodChange,
enabled = enabled,
)
}
if (features.hasListeningModeCycle && cycleMask != null && cycleSubtitle != null) {
SettingsPreferenceItem(
icon = Icons.TwoTone.Loop,
title = stringResource(R.string.device_settings_listening_mode_cycle_label),
subtitle = cycleSubtitle,
value = stringResource(
if (isPro) {
R.string.general_edit_action
} else {
R.string.general_upgrade_action
},
),
onClick = {
if (isPro) {
onShowListeningModeCycleDialogChange(true)
} else {
onUpgrade()
}
},
enabled = if (isPro) enabled else true,
requiresUpgrade = !isPro,
)
if (state.hasCustomLongPressStemAction) {
SettingsInfoBox(
text = stringResource(R.string.stem_actions_long_press_anc_cycle_info),
type = InfoBoxType.INFO,
action = {
TextButton(onClick = onStemActionsClick) {
Text(stringResource(R.string.device_settings_noise_control_open_stem_actions_action))
}
},
)
}
}
if (features.hasAdaptiveAudioNoise && adaptiveNoise != null) {
AdaptiveNoiseSlider(
level = adaptiveNoise.level,
onLevelChange = onAdaptiveAudioNoiseChange,
enabled = enabled,
isAdaptiveMode = ancMode.current == AapSetting.AncMode.Value.ADAPTIVE
|| device.pendingAncMode == AapSetting.AncMode.Value.ADAPTIVE,
)
}
}
NoiseControlCard(
device = device,
features = features,
isPro = isPro,
enabled = enabled,
hasCustomLongPressStemAction = state.hasCustomLongPressStemAction,
showListeningModeCycleDialog = showListeningModeCycleDialog,
onShowListeningModeCycleDialogChange = onShowListeningModeCycleDialogChange,
onAncModeChange = onAncModeChange,
onNcWithOneAirPodChange = onNcWithOneAirPodChange,
onAdaptiveAudioNoiseChange = onAdaptiveAudioNoiseChange,
onAllowOffOptionChange = onAllowOffOptionChange,
onListeningModeCycleChange = onListeningModeCycleChange,
onStemActionsClick = onStemActionsClick,
onUpgrade = onUpgrade,
)
}
}
@@ -646,141 +374,41 @@ fun DeviceSettingsScreen(
features.hasMicrophoneMode
if (showSoundSection) {
item("sound_section") {
SettingsSection(title = stringResource(R.string.device_settings_category_sound_label)) {
if (features.hasPersonalizedVolume && personalizedVol != null) {
SettingsSwitchItem(
icon = Icons.AutoMirrored.TwoTone.VolumeUp,
title = stringResource(R.string.device_settings_personalized_volume_label),
subtitle = stringResource(R.string.device_settings_personalized_volume_description),
checked = personalizedVol.enabled,
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) {
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) {
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,
)
}
}
}
SoundCard(
device = device,
features = features,
isPro = isPro,
enabled = enabled,
onPersonalizedVolumeChange = onPersonalizedVolumeChange,
onToneVolumeChange = onToneVolumeChange,
onMicrophoneModeChange = onMicrophoneModeChange,
onUpgrade = onUpgrade,
onOpenIssueTracker = onOpenIssueTracker,
)
}
}
// ── 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)
(features.hasEndCallMuteMic && device.endCallMuteMic != null) ||
(features.hasPressSpeed && device.pressSpeed != null) ||
(features.hasPressHoldDuration && device.pressHoldDuration != null) ||
(features.hasVolumeSwipe && device.volumeSwipe != null) ||
(features.hasVolumeSwipeLength && device.volumeSwipeLength != 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) {
CallControlSettings(
current = endCallMuteMic,
onChange = onEndCallMuteMicChange,
enabled = enabled,
)
}
if (features.hasPressSpeed && pressSpd != null) {
PressSpeedSetting(
selected = pressSpd.value,
onSelected = onPressSpeedChange,
enabled = enabled,
)
}
if (features.hasPressHoldDuration && pressHold != null) {
PressHoldDurationSetting(
selected = pressHold.value,
onSelected = onPressHoldDurationChange,
enabled = enabled,
)
}
if (features.hasVolumeSwipe && volSwipe != null) {
SettingsSwitchItem(
icon = Icons.TwoTone.Swipe,
title = stringResource(R.string.device_settings_volume_swipe_label),
subtitle = stringResource(R.string.device_settings_volume_swipe_description),
checked = volSwipe.enabled,
onCheckedChange = onVolumeSwipeChange,
enabled = enabled,
)
}
if (features.hasVolumeSwipeLength && volSwipeLen != null) {
VolumeSwipeLengthSetting(
selected = volSwipeLen.value,
onSelected = onVolumeSwipeLengthChange,
enabled = enabled,
)
}
}
ControlsCard(
device = device,
features = features,
isPro = isPro,
enabled = enabled,
onStemActionsClick = onStemActionsClick,
onEndCallMuteMicChange = onEndCallMuteMicChange,
onPressSpeedChange = onPressSpeedChange,
onPressHoldDurationChange = onPressHoldDurationChange,
onVolumeSwipeChange = onVolumeSwipeChange,
onVolumeSwipeLengthChange = onVolumeSwipeLengthChange,
)
}
}
@@ -815,334 +443,10 @@ fun DeviceSettingsScreen(
Spacer(modifier = Modifier.height(16.dp))
}
}
if (
showListeningModeCycleDialog &&
device != null &&
features?.hasListeningModeCycle == true &&
device.isAapConnected
) {
val ancMode = device.ancMode
val currentCycleMask = (device.listeningModeCycle ?: AapSetting.ListeningModeCycle(modeMask = 0x0E)).modeMask
if (ancMode != null) {
ListeningModeCycleDialog(
supportedModes = ancMode.supported,
currentCycleMask = currentCycleMask,
onSave = { newMask ->
val supportsOff = ancMode.supported.contains(AapSetting.AncMode.Value.OFF)
if (supportsOff) {
val offBit = cycleBit(AapSetting.AncMode.Value.OFF)
onOffVisibilityChange((newMask and offBit) != 0, newMask and offBit.inv())
} else {
onListeningModeCycleChange(newMask)
}
},
onDismiss = { onShowListeningModeCycleDialogChange(false) },
)
}
}
if (showAutoConnectConditionDialog && device != null) {
AutoConnectConditionDialog(
current = reactions.autoConnectCondition,
hasEarDetection = features?.hasEarDetection == true,
hasCase = features?.hasCase == true,
onSelect = {
onAutoConnectConditionChange(it)
showAutoConnectConditionDialog = false
},
onDismiss = { showAutoConnectConditionDialog = false },
)
}
}
}
@Composable
private fun ReactionsDivider() {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
}
@Composable
private fun NoiseControlCurrentModeControl(
currentMode: AapSetting.AncMode.Value,
pendingMode: AapSetting.AncMode.Value?,
supportedModes: List<AapSetting.AncMode.Value>,
onModeSelected: (AapSetting.AncMode.Value) -> Unit,
enabled: Boolean,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerLow,
shape = RoundedCornerShape(16.dp),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp)) {
SettingsCompoundHeader(
icon = Icons.TwoTone.Headphones,
title = stringResource(R.string.device_settings_noise_control_current_mode_label),
subtitle = null,
enabled = enabled,
)
Spacer(modifier = Modifier.height(12.dp))
AncModeSelector(
currentMode = currentMode,
supportedModes = supportedModes,
onModeSelected = onModeSelected,
pendingMode = pendingMode,
enabled = enabled,
)
}
}
}
@Composable
private fun ListeningModeCycleDialog(
supportedModes: List<AapSetting.AncMode.Value>,
currentCycleMask: Int,
onSave: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val supportedMask = supportedModes.fold(0) { mask, mode -> mask or cycleBit(mode) }
var draftMask by remember(currentCycleMask, supportedMask) {
mutableIntStateOf(currentCycleMask and supportedMask)
}
val selectedCount = Integer.bitCount(draftMask and supportedMask)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.device_settings_listening_mode_cycle_dialog_title)) },
text = {
Column {
Text(
text = stringResource(R.string.device_settings_listening_mode_cycle_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(16.dp))
Column(modifier = Modifier.selectableGroup()) {
supportedModes.forEach { mode ->
val bit = cycleBit(mode)
val isSelected = (draftMask and bit) != 0
val canToggle = !isSelected || selectedCount > 2
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
enabled = canToggle,
role = Role.Checkbox,
onClick = {
draftMask = if (isSelected) {
draftMask and bit.inv()
} else {
draftMask or bit
}
},
)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = isSelected,
onCheckedChange = null,
enabled = canToggle,
)
Icon(
imageVector = mode.icon(),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (canToggle) 1f else 0.5f),
modifier = Modifier
.padding(start = 12.dp)
.align(Alignment.CenterVertically),
)
Text(
text = mode.listeningModeCycleDialogLabel(context),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (canToggle) 1f else 0.5f),
modifier = Modifier.padding(start = 12.dp),
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.device_settings_listening_mode_cycle_minimum),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
TextButton(
onClick = {
onSave(draftMask)
onDismiss()
},
enabled = selectedCount >= 2,
) {
Text(text = stringResource(R.string.general_save_action))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(R.string.general_cancel_action))
}
},
)
}
@Composable
private fun ToneVolumeSlider(
level: Int,
onLevelChange: (Int) -> Unit,
enabled: Boolean,
) {
var sliderValue by remember(level) { mutableIntStateOf(level) }
SettingsSliderItem(
icon = Icons.AutoMirrored.TwoTone.VolumeUp,
title = stringResource(R.string.device_settings_tone_volume_label),
subtitle = stringResource(R.string.device_settings_tone_volume_description),
value = sliderValue.toFloat(),
onValueChange = { sliderValue = it.toInt() },
onValueChangeFinished = { onLevelChange(sliderValue) },
valueRange = 15f..100f,
steps = 84,
enabled = enabled,
valueLabel = { "${it.toInt()}%" },
)
}
@Composable
private fun AdaptiveNoiseSlider(
level: Int,
onLevelChange: (Int) -> Unit,
enabled: Boolean,
isAdaptiveMode: Boolean,
) {
var sliderValue by remember(level) { mutableIntStateOf(level) }
val subtitleRes = if (isAdaptiveMode) {
R.string.device_settings_adaptive_noise_description
} else {
R.string.device_settings_adaptive_noise_requires_adaptive
}
SettingsSliderItem(
icon = Icons.TwoTone.GraphicEq,
title = stringResource(R.string.device_settings_adaptive_noise_label),
subtitle = stringResource(subtitleRes),
value = sliderValue.toFloat(),
onValueChange = { sliderValue = it.toInt() },
onValueChangeFinished = { onLevelChange(sliderValue) },
valueRange = 0f..100f,
steps = 99,
enabled = enabled && isAdaptiveMode,
valueLabel = { "${it.toInt()}%" },
)
}
@Composable
internal fun SettingsCompoundHeader(
icon: ImageVector,
title: String,
subtitle: String?,
enabled: Boolean,
) {
Row(modifier = Modifier.fillMaxWidth()) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 0.6f else 0.3f),
modifier = Modifier.padding(end = 16.dp, top = 4.dp),
)
Column {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 1f else 0.5f),
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (enabled) 1f else 0.5f),
)
}
}
}
}
@Composable
internal fun <T> SegmentedSettingRow(
icon: ImageVector,
title: String,
options: List<Pair<String, T>>,
selected: T,
onSelected: (T) -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier,
subtitle: String? = null,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerLow,
shape = RoundedCornerShape(16.dp),
modifier = modifier.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp)) {
SettingsCompoundHeader(
icon = icon,
title = title,
subtitle = subtitle,
enabled = enabled,
)
Spacer(modifier = Modifier.height(12.dp))
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
options.forEachIndexed { index, (label, value) ->
SegmentedButton(
selected = value == selected,
onClick = { if (enabled) onSelected(value) },
enabled = enabled,
shape = SegmentedButtonDefaults.itemShape(index, options.size),
label = {
Text(
text = label,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
)
}
}
}
}
}
private fun cycleBit(mode: AapSetting.AncMode.Value): Int = when (mode) {
AapSetting.AncMode.Value.OFF -> 0x01
AapSetting.AncMode.Value.ON -> 0x02
AapSetting.AncMode.Value.TRANSPARENCY -> 0x04
AapSetting.AncMode.Value.ADAPTIVE -> 0x08
}
private fun listeningModeCycleSummary(
context: android.content.Context,
supportedModes: List<AapSetting.AncMode.Value>,
cycleMask: Int,
): String = supportedModes
.filter { mode -> (cycleMask and cycleBit(mode)) != 0 }
.joinToString(separator = "") { it.shortLabel(context) }
private fun AapSetting.AncMode.Value.listeningModeCycleDialogLabel(
context: android.content.Context,
): String = when (this) {
AapSetting.AncMode.Value.OFF -> context.getString(R.string.device_settings_allow_off_label)
AapSetting.AncMode.Value.ON -> context.getString(R.string.device_settings_listening_mode_cycle_anc)
AapSetting.AncMode.Value.TRANSPARENCY -> context.getString(R.string.device_settings_listening_mode_cycle_transparency)
AapSetting.AncMode.Value.ADAPTIVE -> context.getString(R.string.device_settings_listening_mode_cycle_adaptive)
}
private fun previewFullState(isPro: Boolean) = DeviceSettingsViewModel.State(
internal fun previewFullState(isPro: Boolean) = DeviceSettingsViewModel.State(
device = PodDevice(
profileId = "preview",
label = "My AirPods Pro",
@@ -0,0 +1,118 @@
package eu.darken.capod.main.ui.devicesettings.cards
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Swipe
import androidx.compose.material.icons.twotone.TouchApp
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.settings.SettingsPreferenceItem
import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.common.settings.SettingsSwitchItem
import eu.darken.capod.main.ui.devicesettings.components.CallControlSettings
import eu.darken.capod.main.ui.devicesettings.components.PressHoldDurationSetting
import eu.darken.capod.main.ui.devicesettings.components.PressSpeedSetting
import eu.darken.capod.main.ui.devicesettings.components.VolumeSwipeLengthSetting
import eu.darken.capod.main.ui.devicesettings.previewFullState
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
@Composable
internal fun ControlsCard(
device: PodDevice,
features: PodModel.Features,
isPro: Boolean,
enabled: Boolean,
onStemActionsClick: () -> Unit = {},
onEndCallMuteMicChange: (AapSetting.EndCallMuteMic.MuteMicMode, AapSetting.EndCallMuteMic.EndCallMode) -> Unit = { _, _ -> },
onPressSpeedChange: (AapSetting.PressSpeed.Value) -> Unit = {},
onPressHoldDurationChange: (AapSetting.PressHoldDuration.Value) -> Unit = {},
onVolumeSwipeChange: (Boolean) -> Unit = {},
onVolumeSwipeLengthChange: (AapSetting.VolumeSwipeLength.Value) -> Unit = {},
) {
val pressSpd = device.pressSpeed
val pressHold = device.pressHoldDuration
val volSwipe = device.volumeSwipe
val volSwipeLen = device.volumeSwipeLength
val endCallMuteMic = device.endCallMuteMic
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) {
CallControlSettings(
current = endCallMuteMic,
onChange = onEndCallMuteMicChange,
enabled = enabled,
)
}
if (features.hasPressSpeed && pressSpd != null) {
PressSpeedSetting(
selected = pressSpd.value,
onSelected = onPressSpeedChange,
enabled = enabled,
)
}
if (features.hasPressHoldDuration && pressHold != null) {
PressHoldDurationSetting(
selected = pressHold.value,
onSelected = onPressHoldDurationChange,
enabled = enabled,
)
}
if (features.hasVolumeSwipe && volSwipe != null) {
SettingsSwitchItem(
icon = Icons.TwoTone.Swipe,
title = stringResource(R.string.device_settings_volume_swipe_label),
subtitle = stringResource(R.string.device_settings_volume_swipe_description),
checked = volSwipe.enabled,
onCheckedChange = onVolumeSwipeChange,
enabled = enabled,
)
}
if (features.hasVolumeSwipeLength && volSwipeLen != null) {
VolumeSwipeLengthSetting(
selected = volSwipeLen.value,
onSelected = onVolumeSwipeLengthChange,
enabled = enabled,
)
}
}
}
@Preview2
@Composable
private fun ControlsCardPreview() = PreviewWrapper {
val state = previewFullState(isPro = true)
val device = state.device!!
ControlsCard(
device = device,
features = device.model.features,
isPro = state.isPro,
enabled = device.isAapReady,
)
}
@Preview2
@Composable
private fun ControlsCardNonProPreview() = PreviewWrapper {
val state = previewFullState(isPro = false)
val device = state.device!!
ControlsCard(
device = device,
features = device.model.features,
isPro = state.isPro,
enabled = device.isAapReady,
)
}
@@ -0,0 +1,409 @@
package eu.darken.capod.main.ui.devicesettings.cards
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.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.DoNotDisturbOn
import androidx.compose.material.icons.twotone.GraphicEq
import androidx.compose.material.icons.twotone.Headphones
import androidx.compose.material.icons.twotone.LooksOne
import androidx.compose.material.icons.twotone.Loop
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.HorizontalDivider
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.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
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
import eu.darken.capod.common.settings.SettingsSliderItem
import eu.darken.capod.common.settings.SettingsSwitchItem
import eu.darken.capod.main.ui.components.icon
import eu.darken.capod.main.ui.components.shortLabel
import eu.darken.capod.main.ui.devicesettings.components.SettingsCompoundHeader
import eu.darken.capod.main.ui.devicesettings.previewFullState
import eu.darken.capod.main.ui.overview.cards.components.AncModeSelector
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.resolvedAncCycleMask
import eu.darken.capod.monitor.core.visibleAncModes
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
@Composable
internal fun NoiseControlCard(
device: PodDevice,
features: PodModel.Features,
isPro: Boolean,
enabled: Boolean,
hasCustomLongPressStemAction: Boolean = false,
showListeningModeCycleDialog: Boolean = false,
onShowListeningModeCycleDialogChange: (Boolean) -> Unit = {},
onAncModeChange: (AapSetting.AncMode.Value) -> Unit = {},
onNcWithOneAirPodChange: (Boolean) -> Unit = {},
onAdaptiveAudioNoiseChange: (Int) -> Unit = {},
onAllowOffOptionChange: (Boolean) -> Unit = {},
onListeningModeCycleChange: (Int) -> Unit = {},
onStemActionsClick: () -> Unit = {},
onUpgrade: () -> Unit = {},
) {
val context = LocalContext.current
val ancMode = device.ancMode ?: return
val adaptiveNoise = device.adaptiveAudioNoise
val cycleMask = device.resolvedAncCycleMask
val cycleSummary = if (cycleMask != null) listeningModeCycleSummary(context, ancMode.supported, cycleMask) else null
val cycleSubtitle = if (cycleSummary != null) {
buildString {
append(cycleSummary)
append('\n')
append(
context.getString(
if (hasCustomLongPressStemAction) {
R.string.device_settings_listening_mode_cycle_summary_helper_override
} else {
R.string.device_settings_listening_mode_cycle_summary_helper
}
),
)
}
} else {
null
}
SettingsSection(title = stringResource(R.string.device_settings_noise_control_label)) {
NoiseControlCurrentModeControl(
currentMode = ancMode.current,
pendingMode = device.pendingAncMode,
supportedModes = device.visibleAncModes,
onModeSelected = onAncModeChange,
enabled = enabled,
)
val ncWithOneAirPod = device.ncWithOneAirPod
val showNcWithOneAirPod = features.hasNcOneAirpod && ncWithOneAirPod != null
val hasNoiseExtras = (features.hasAdaptiveAudioNoise && adaptiveNoise != null) ||
features.hasListeningModeCycle ||
showNcWithOneAirPod
if (hasNoiseExtras) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
}
if (features.hasListeningModeCycle && cycleMask != null && cycleSubtitle != null) {
SettingsPreferenceItem(
icon = Icons.TwoTone.Loop,
title = stringResource(R.string.device_settings_listening_mode_cycle_label),
subtitle = cycleSubtitle,
value = stringResource(
if (isPro) {
R.string.general_edit_action
} else {
R.string.general_upgrade_action
},
),
onClick = {
if (isPro) {
onShowListeningModeCycleDialogChange(true)
} else {
onUpgrade()
}
},
enabled = if (isPro) enabled else true,
requiresUpgrade = !isPro,
)
if (hasCustomLongPressStemAction) {
SettingsInfoBox(
text = stringResource(R.string.stem_actions_long_press_anc_cycle_info),
type = InfoBoxType.INFO,
action = {
TextButton(onClick = onStemActionsClick) {
Text(stringResource(R.string.device_settings_noise_control_open_stem_actions_action))
}
},
)
}
}
if (features.hasAllowOffOption) {
SettingsSwitchItem(
icon = Icons.TwoTone.DoNotDisturbOn,
title = stringResource(R.string.device_settings_allow_off_label),
subtitle = stringResource(R.string.device_settings_allow_off_description),
checked = device.allowOffOption?.enabled != false,
onCheckedChange = { onAllowOffOptionChange(it) },
enabled = if (isPro) enabled else true,
requiresUpgrade = !isPro,
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
}
if (showNcWithOneAirPod) {
SettingsSwitchItem(
icon = Icons.TwoTone.LooksOne,
title = stringResource(R.string.device_settings_nc_one_airpod_label),
subtitle = stringResource(R.string.device_settings_nc_one_airpod_description),
checked = ncWithOneAirPod.enabled,
onCheckedChange = onNcWithOneAirPodChange,
enabled = enabled,
)
}
if (features.hasAdaptiveAudioNoise && adaptiveNoise != null) {
AdaptiveNoiseSlider(
level = adaptiveNoise.level,
onLevelChange = onAdaptiveAudioNoiseChange,
enabled = enabled,
isAdaptiveMode = ancMode.current == AapSetting.AncMode.Value.ADAPTIVE
|| device.pendingAncMode == AapSetting.AncMode.Value.ADAPTIVE,
)
}
}
if (showListeningModeCycleDialog && features.hasListeningModeCycle && device.isAapConnected) {
val currentCycleMask = (device.listeningModeCycle ?: AapSetting.ListeningModeCycle(modeMask = 0x0E)).modeMask
// When Allow Off is confirmed disabled, strip OFF from the dialog entirely so
// the user can't (re-)introduce it invisibly via a stale cycle-mask bit.
val allowOffEnabled = device.allowOffOption?.enabled != false
val dialogSupportedModes = if (allowOffEnabled) {
ancMode.supported
} else {
ancMode.supported - AapSetting.AncMode.Value.OFF
}
ListeningModeCycleDialog(
supportedModes = dialogSupportedModes,
currentCycleMask = currentCycleMask,
onSave = { newMask -> onListeningModeCycleChange(newMask) },
onDismiss = { onShowListeningModeCycleDialogChange(false) },
)
}
}
@Composable
private fun NoiseControlCurrentModeControl(
currentMode: AapSetting.AncMode.Value,
pendingMode: AapSetting.AncMode.Value?,
supportedModes: List<AapSetting.AncMode.Value>,
onModeSelected: (AapSetting.AncMode.Value) -> Unit,
enabled: Boolean,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerLow,
shape = RoundedCornerShape(16.dp),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp)) {
SettingsCompoundHeader(
icon = Icons.TwoTone.Headphones,
title = stringResource(R.string.device_settings_noise_control_current_mode_label),
subtitle = null,
enabled = enabled,
)
Spacer(modifier = Modifier.height(12.dp))
AncModeSelector(
currentMode = currentMode,
supportedModes = supportedModes,
onModeSelected = onModeSelected,
pendingMode = pendingMode,
enabled = enabled,
)
}
}
}
@Composable
private fun AdaptiveNoiseSlider(
level: Int,
onLevelChange: (Int) -> Unit,
enabled: Boolean,
isAdaptiveMode: Boolean,
) {
var sliderValue by remember(level) { mutableIntStateOf(level) }
val subtitleRes = if (isAdaptiveMode) {
R.string.device_settings_adaptive_noise_description
} else {
R.string.device_settings_adaptive_noise_requires_adaptive
}
SettingsSliderItem(
icon = Icons.TwoTone.GraphicEq,
title = stringResource(R.string.device_settings_adaptive_noise_label),
subtitle = stringResource(subtitleRes),
value = sliderValue.toFloat(),
onValueChange = { sliderValue = it.toInt() },
onValueChangeFinished = { onLevelChange(sliderValue) },
valueRange = 0f..100f,
steps = 99,
enabled = enabled && isAdaptiveMode,
valueLabel = { "${it.toInt()}%" },
)
}
@Composable
private fun ListeningModeCycleDialog(
supportedModes: List<AapSetting.AncMode.Value>,
currentCycleMask: Int,
onSave: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val supportedMask = supportedModes.fold(0) { mask, mode -> mask or cycleBit(mode) }
var draftMask by remember(currentCycleMask, supportedMask) {
mutableIntStateOf(currentCycleMask and supportedMask)
}
val selectedCount = Integer.bitCount(draftMask and supportedMask)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.device_settings_listening_mode_cycle_dialog_title)) },
text = {
Column {
Text(
text = stringResource(R.string.device_settings_listening_mode_cycle_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(16.dp))
Column(modifier = Modifier.selectableGroup()) {
supportedModes.forEach { mode ->
val bit = cycleBit(mode)
val isSelected = (draftMask and bit) != 0
val canToggle = !isSelected || selectedCount > 2
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
enabled = canToggle,
role = Role.Checkbox,
onClick = {
draftMask = if (isSelected) {
draftMask and bit.inv()
} else {
draftMask or bit
}
},
)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = isSelected,
onCheckedChange = null,
enabled = canToggle,
)
Icon(
imageVector = mode.icon(),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (canToggle) 1f else 0.5f),
modifier = Modifier
.padding(start = 12.dp)
.align(Alignment.CenterVertically),
)
Text(
text = mode.listeningModeCycleDialogLabel(context),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (canToggle) 1f else 0.5f),
modifier = Modifier.padding(start = 12.dp),
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.device_settings_listening_mode_cycle_minimum),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
TextButton(
onClick = {
onSave(draftMask)
onDismiss()
},
enabled = selectedCount >= 2,
) {
Text(text = stringResource(R.string.general_save_action))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(R.string.general_cancel_action))
}
},
)
}
private fun cycleBit(mode: AapSetting.AncMode.Value): Int = when (mode) {
AapSetting.AncMode.Value.OFF -> 0x01
AapSetting.AncMode.Value.ON -> 0x02
AapSetting.AncMode.Value.TRANSPARENCY -> 0x04
AapSetting.AncMode.Value.ADAPTIVE -> 0x08
}
private fun listeningModeCycleSummary(
context: android.content.Context,
supportedModes: List<AapSetting.AncMode.Value>,
cycleMask: Int,
): String = supportedModes
.filter { mode -> (cycleMask and cycleBit(mode)) != 0 }
.joinToString(separator = "") { it.shortLabel(context) }
private fun AapSetting.AncMode.Value.listeningModeCycleDialogLabel(
context: android.content.Context,
): String = when (this) {
AapSetting.AncMode.Value.OFF -> context.getString(R.string.device_settings_listening_mode_cycle_off)
AapSetting.AncMode.Value.ON -> context.getString(R.string.device_settings_listening_mode_cycle_anc)
AapSetting.AncMode.Value.TRANSPARENCY -> context.getString(R.string.device_settings_listening_mode_cycle_transparency)
AapSetting.AncMode.Value.ADAPTIVE -> context.getString(R.string.device_settings_listening_mode_cycle_adaptive)
}
@Preview2
@Composable
private fun NoiseControlCardPreview() = PreviewWrapper {
val state = previewFullState(isPro = true)
val device = state.device!!
NoiseControlCard(
device = device,
features = device.model.features,
isPro = state.isPro,
enabled = device.isAapReady,
)
}
@Preview2
@Composable
private fun NoiseControlCardNonProPreview() = PreviewWrapper {
val state = previewFullState(isPro = false)
val device = state.device!!
NoiseControlCard(
device = device,
features = device.model.features,
isPro = state.isPro,
enabled = device.isAapReady,
)
}
@@ -0,0 +1,263 @@
package eu.darken.capod.main.ui.devicesettings.cards
import android.os.Build
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.Message
import androidx.compose.material.icons.twotone.BluetoothConnected
import androidx.compose.material.icons.twotone.Hearing
import androidx.compose.material.icons.twotone.LooksOne
import androidx.compose.material.icons.twotone.Nightlight
import androidx.compose.material.icons.twotone.PauseCircle
import androidx.compose.material.icons.twotone.PlayCircle
import androidx.compose.material.icons.twotone.Workspaces
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.settings.InfoBoxType
import eu.darken.capod.common.settings.SettingsBaseItem
import eu.darken.capod.common.settings.SettingsInfoBox
import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.common.settings.SettingsSwitchItem
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.ui.devicesettings.dialogs.AutoConnectConditionDialog
import eu.darken.capod.main.ui.devicesettings.previewFullState
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
@Composable
internal fun ReactionsCard(
device: PodDevice,
features: PodModel.Features,
isPro: Boolean,
monitorMode: MonitorMode,
onAutoPlayChange: (Boolean) -> Unit = {},
onAutoPauseChange: (Boolean) -> Unit = {},
onOnePodModeChange: (Boolean) -> Unit = {},
onConversationalAwarenessChange: (Boolean) -> Unit = {},
onSleepDetectionChange: (Boolean) -> Unit = {},
onAutoConnectChange: (Boolean) -> Unit = {},
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
onFixMonitorMode: () -> Unit = {},
onOpenIssueTracker: () -> Unit = {},
) {
val reactions = device.reactions
val enabled = device.isAapReady
var showAutoConnectConditionDialog by remember { mutableStateOf(false) }
SettingsSection(title = stringResource(R.string.settings_reaction_label)) {
if (features.hasEarDetection) {
SettingsSwitchItem(
icon = Icons.TwoTone.PlayCircle,
title = stringResource(R.string.settings_autopplay_label),
subtitle = stringResource(R.string.settings_autoplay_description),
checked = reactions.autoPlay,
onCheckedChange = onAutoPlayChange,
requiresUpgrade = !isPro,
)
SettingsSwitchItem(
icon = Icons.TwoTone.PauseCircle,
title = stringResource(R.string.settings_autopause_label),
subtitle = stringResource(R.string.settings_autopause_description),
checked = reactions.autoPause,
onCheckedChange = onAutoPauseChange,
requiresUpgrade = !isPro,
)
if (features.hasDualPods) {
val onePodModeActive = reactions.onePodMode ||
reactions.autoPlay ||
reactions.autoPause ||
(reactions.autoConnect && reactions.autoConnectCondition == AutoConnectCondition.IN_EAR)
SettingsBaseItem(
title = stringResource(R.string.settings_onepod_mode_label),
subtitle = stringResource(R.string.settings_onepod_mode_description),
icon = Icons.TwoTone.LooksOne,
onClick = { if (onePodModeActive) onOnePodModeChange(!reactions.onePodMode) },
enabled = onePodModeActive,
trailingContent = {
Switch(
checked = reactions.onePodMode,
onCheckedChange = onOnePodModeChange,
enabled = onePodModeActive,
modifier = Modifier.padding(start = 16.dp),
)
},
)
}
val earDetectionWarningVisible =
(
reactions.autoPlay ||
reactions.autoPause ||
(reactions.autoConnect && reactions.autoConnectCondition == AutoConnectCondition.IN_EAR)
) && !device.isAapConnected
if (earDetectionWarningVisible) {
SettingsInfoBox(
text = stringResource(R.string.settings_eardetection_info_description),
)
}
ReactionsDivider()
}
if (device.isAapConnected) {
val convAwareness = device.conversationalAwareness
val hasAnyAapReaction =
(features.hasConversationAwareness && convAwareness != null) ||
features.hasSleepDetection
if (features.hasConversationAwareness && convAwareness != null) {
SettingsSwitchItem(
icon = Icons.TwoTone.Hearing,
title = stringResource(R.string.conversation_awareness_label),
subtitle = stringResource(R.string.device_settings_conversation_awareness_description),
checked = convAwareness.enabled,
onCheckedChange = onConversationalAwarenessChange,
enabled = enabled,
)
}
if (features.hasSleepDetection) {
val sleepDet = device.sleepDetection
?: AapSetting.SleepDetection(enabled = true)
SettingsSwitchItem(
icon = Icons.TwoTone.Nightlight,
title = stringResource(R.string.device_settings_sleep_detection_label),
subtitle = stringResource(R.string.device_settings_sleep_detection_description),
checked = sleepDet.enabled,
onCheckedChange = onSleepDetectionChange,
enabled = enabled,
)
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()
}
}
SettingsSwitchItem(
icon = Icons.TwoTone.BluetoothConnected,
title = stringResource(R.string.settings_autoconnect_label),
subtitle = stringResource(R.string.settings_autoconnect_description),
checked = reactions.autoConnect,
onCheckedChange = onAutoConnectChange,
)
SettingsBaseItem(
title = stringResource(R.string.settings_autoconnect_condition_label),
subtitle = stringResource(reactions.autoConnectCondition.labelRes),
icon = Icons.TwoTone.Workspaces,
onClick = { if (reactions.autoConnect) showAutoConnectConditionDialog = true },
enabled = reactions.autoConnect,
)
if (reactions.autoConnect && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
SettingsInfoBox(
text = stringResource(R.string.settings_autoconnect_info_android12),
)
}
ReactionsDivider()
if (features.hasCase) {
SettingsSwitchItem(
icon = Icons.AutoMirrored.TwoTone.Message,
title = stringResource(R.string.settings_popup_caseopen_label),
subtitle = stringResource(R.string.settings_popup_caseopen_description),
checked = reactions.showPopUpOnCaseOpen,
onCheckedChange = onShowPopUpOnCaseOpenChange,
requiresUpgrade = !isPro,
)
}
SettingsSwitchItem(
icon = Icons.AutoMirrored.TwoTone.Message,
title = stringResource(R.string.settings_popup_connected_label),
subtitle = stringResource(R.string.settings_popup_connected_description),
checked = reactions.showPopUpOnConnection,
onCheckedChange = onShowPopUpOnConnectionChange,
requiresUpgrade = !isPro,
)
val anyPopupEnabled = reactions.showPopUpOnCaseOpen || reactions.showPopUpOnConnection
if (anyPopupEnabled && 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),
)
}
}
if (showAutoConnectConditionDialog) {
AutoConnectConditionDialog(
current = reactions.autoConnectCondition,
hasEarDetection = features.hasEarDetection,
hasCase = features.hasCase,
onSelect = {
onAutoConnectConditionChange(it)
showAutoConnectConditionDialog = false
},
onDismiss = { showAutoConnectConditionDialog = false },
)
}
}
@Composable
private fun ReactionsDivider() {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
}
@Preview2
@Composable
private fun ReactionsCardPreview() = PreviewWrapper {
val state = previewFullState(isPro = true)
val device = state.device!!
ReactionsCard(
device = device,
features = device.model.features,
isPro = state.isPro,
monitorMode = state.monitorMode,
)
}
@Preview2
@Composable
private fun ReactionsCardNonProPreview() = PreviewWrapper {
val state = previewFullState(isPro = false)
val device = state.device!!
ReactionsCard(
device = device,
features = device.model.features,
isPro = state.isPro,
monitorMode = state.monitorMode,
)
}
@@ -0,0 +1,159 @@
package eu.darken.capod.main.ui.devicesettings.cards
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.VolumeUp
import androidx.compose.material.icons.twotone.Mic
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.settings.InfoBoxType
import eu.darken.capod.common.settings.SettingsBaseItem
import eu.darken.capod.common.settings.SettingsInfoBox
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.main.ui.devicesettings.components.SegmentedSettingRow
import eu.darken.capod.main.ui.devicesettings.previewFullState
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
@Composable
internal fun SoundCard(
device: PodDevice,
features: PodModel.Features,
isPro: Boolean,
enabled: Boolean,
onPersonalizedVolumeChange: (Boolean) -> Unit = {},
onToneVolumeChange: (Int) -> Unit = {},
onMicrophoneModeChange: (AapSetting.MicrophoneMode.Mode) -> Unit = {},
onUpgrade: () -> Unit = {},
onOpenIssueTracker: () -> Unit = {},
) {
val personalizedVol = device.personalizedVolume
val toneVol = device.toneVolume
SettingsSection(title = stringResource(R.string.device_settings_category_sound_label)) {
if (features.hasPersonalizedVolume && personalizedVol != null) {
SettingsSwitchItem(
icon = Icons.AutoMirrored.TwoTone.VolumeUp,
title = stringResource(R.string.device_settings_personalized_volume_label),
subtitle = stringResource(R.string.device_settings_personalized_volume_description),
checked = personalizedVol.enabled,
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) {
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) {
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,
)
}
}
}
}
@Composable
private fun ToneVolumeSlider(
level: Int,
onLevelChange: (Int) -> Unit,
enabled: Boolean,
) {
var sliderValue by remember(level) { mutableIntStateOf(level) }
SettingsSliderItem(
icon = Icons.AutoMirrored.TwoTone.VolumeUp,
title = stringResource(R.string.device_settings_tone_volume_label),
subtitle = stringResource(R.string.device_settings_tone_volume_description),
value = sliderValue.toFloat(),
onValueChange = { sliderValue = it.toInt() },
onValueChangeFinished = { onLevelChange(sliderValue) },
valueRange = 15f..100f,
steps = 84,
enabled = enabled,
valueLabel = { "${it.toInt()}%" },
)
}
@Preview2
@Composable
private fun SoundCardPreview() = PreviewWrapper {
val state = previewFullState(isPro = true)
val device = state.device!!
SoundCard(
device = device,
features = device.model.features,
isPro = state.isPro,
enabled = device.isAapReady,
)
}
@Preview2
@Composable
private fun SoundCardNonProPreview() = PreviewWrapper {
val state = previewFullState(isPro = false)
val device = state.device!!
SoundCard(
device = device,
features = device.model.features,
isPro = state.isPro,
enabled = device.isAapReady,
)
}
@@ -24,7 +24,6 @@ import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.main.ui.devicesettings.SettingsCompoundHeader
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
@Composable
@@ -7,7 +7,6 @@ import androidx.compose.ui.res.stringResource
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.main.ui.devicesettings.SegmentedSettingRow
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
@Composable
@@ -7,7 +7,6 @@ import androidx.compose.ui.res.stringResource
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.main.ui.devicesettings.SegmentedSettingRow
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
@Composable
@@ -0,0 +1,82 @@
package eu.darken.capod.main.ui.devicesettings.components
import androidx.compose.foundation.layout.Column
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.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Mic
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
@Composable
internal fun <T> SegmentedSettingRow(
icon: ImageVector,
title: String,
options: List<Pair<String, T>>,
selected: T,
onSelected: (T) -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier,
subtitle: String? = null,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerLow,
shape = RoundedCornerShape(16.dp),
modifier = modifier.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp)) {
SettingsCompoundHeader(
icon = icon,
title = title,
subtitle = subtitle,
enabled = enabled,
)
Spacer(modifier = Modifier.height(12.dp))
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
options.forEachIndexed { index, (label, value) ->
SegmentedButton(
selected = value == selected,
onClick = { if (enabled) onSelected(value) },
enabled = enabled,
shape = SegmentedButtonDefaults.itemShape(index, options.size),
label = {
Text(
text = label,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
)
}
}
}
}
}
@Preview2
@Composable
private fun SegmentedSettingRowPreview() = PreviewWrapper {
SegmentedSettingRow(
icon = Icons.TwoTone.Mic,
title = "Microphone Mode",
subtitle = "Which microphone to use during calls",
options = listOf("Auto" to "auto", "Left" to "left", "Right" to "right"),
selected = "auto",
onSelected = {},
enabled = true,
)
}
@@ -0,0 +1,70 @@
package eu.darken.capod.main.ui.devicesettings.components
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.material.icons.Icons
import androidx.compose.material.icons.twotone.Headphones
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
@Composable
internal fun SettingsCompoundHeader(
icon: ImageVector,
title: String,
subtitle: String?,
enabled: Boolean,
) {
Row(modifier = Modifier.fillMaxWidth()) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 0.6f else 0.3f),
modifier = Modifier.padding(end = 16.dp),
)
Column {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 1f else 0.5f),
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (enabled) 1f else 0.5f),
)
}
}
}
}
@Preview2
@Composable
private fun SettingsCompoundHeaderPreview() = PreviewWrapper {
SettingsCompoundHeader(
icon = Icons.TwoTone.Headphones,
title = "Noise Control",
subtitle = "Switch between Off, On, Transparency, Adaptive",
enabled = true,
)
}
@Preview2
@Composable
private fun SettingsCompoundHeaderDisabledPreview() = PreviewWrapper {
SettingsCompoundHeader(
icon = Icons.TwoTone.Headphones,
title = "Noise Control",
subtitle = null,
enabled = false,
)
}
@@ -7,7 +7,6 @@ import androidx.compose.ui.res.stringResource
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.main.ui.devicesettings.SegmentedSettingRow
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
@Composable