diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt index 3cefa3db..fa20f950 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt @@ -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, - 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, - 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 SegmentedSettingRow( - icon: ImageVector, - title: String, - options: List>, - 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, - 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", diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ControlsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ControlsCard.kt new file mode 100644 index 00000000..fdcc9907 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ControlsCard.kt @@ -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, + ) +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/NoiseControlCard.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/NoiseControlCard.kt new file mode 100644 index 00000000..d7be34ba --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/NoiseControlCard.kt @@ -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, + 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, + 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, + 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, + ) +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ReactionsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ReactionsCard.kt new file mode 100644 index 00000000..b75ba246 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ReactionsCard.kt @@ -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, + ) +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/SoundCard.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/SoundCard.kt new file mode 100644 index 00000000..9af0dd59 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/SoundCard.kt @@ -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, + ) +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/CallControlSettings.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/CallControlSettings.kt index 84a44b6e..ddc3cdd1 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/CallControlSettings.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/CallControlSettings.kt @@ -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 diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/PressHoldDurationSetting.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/PressHoldDurationSetting.kt index d204e198..d49469af 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/PressHoldDurationSetting.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/PressHoldDurationSetting.kt @@ -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 diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/PressSpeedSetting.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/PressSpeedSetting.kt index 1038bb54..5ec7d6f6 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/PressSpeedSetting.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/PressSpeedSetting.kt @@ -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 diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/SegmentedSettingRow.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/SegmentedSettingRow.kt new file mode 100644 index 00000000..7dfa6d5d --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/SegmentedSettingRow.kt @@ -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 SegmentedSettingRow( + icon: ImageVector, + title: String, + options: List>, + 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, + ) +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/SettingsCompoundHeader.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/SettingsCompoundHeader.kt new file mode 100644 index 00000000..ed381ed9 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/SettingsCompoundHeader.kt @@ -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, + ) +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/VolumeSwipeLengthSetting.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/VolumeSwipeLengthSetting.kt index f20c65cf..28256d0a 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/VolumeSwipeLengthSetting.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/components/VolumeSwipeLengthSetting.kt @@ -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