mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 02:36:12 -04:00
feat: Add device settings screen for AAP controls
This commit is contained in:
@@ -21,6 +21,9 @@ object Nav {
|
||||
|
||||
@Serializable
|
||||
data object Upgrade : Main
|
||||
|
||||
@Serializable
|
||||
data class DeviceSettings(val address: String) : Main
|
||||
}
|
||||
|
||||
sealed interface Settings : NavigationDestination {
|
||||
|
||||
@@ -26,6 +26,7 @@ fun SettingsSliderItem(
|
||||
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
|
||||
steps: Int = 0,
|
||||
enabled: Boolean = true,
|
||||
onValueChangeFinished: (() -> Unit)? = null,
|
||||
valueLabel: ((Float) -> String)? = null,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
@@ -49,6 +50,7 @@ fun SettingsSliderItem(
|
||||
Slider(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
onValueChangeFinished = onValueChangeFinished,
|
||||
valueRange = valueRange,
|
||||
steps = steps,
|
||||
enabled = enabled,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package eu.darken.capod.main.ui.devicesettings
|
||||
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import eu.darken.capod.common.navigation.Nav
|
||||
import eu.darken.capod.common.navigation.NavigationEntry
|
||||
import javax.inject.Inject
|
||||
|
||||
class DeviceSettingsNavigation @Inject constructor() : NavigationEntry {
|
||||
override fun EntryProviderScope<NavKey>.setup() {
|
||||
entry<Nav.Main.DeviceSettings> { key ->
|
||||
DeviceSettingsScreenHost(address = key.address)
|
||||
}
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class Mod {
|
||||
@Binds
|
||||
@IntoSet
|
||||
abstract fun bind(entry: DeviceSettingsNavigation): NavigationEntry
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
package eu.darken.capod.main.ui.devicesettings
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.twotone.VolumeUp
|
||||
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.Speed
|
||||
import androidx.compose.material.icons.twotone.Swipe
|
||||
import androidx.compose.material.icons.twotone.Timer
|
||||
import androidx.compose.material.icons.twotone.TouchApp
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Text
|
||||
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.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.error.ErrorEventHandler
|
||||
import eu.darken.capod.common.navigation.NavigationEventHandler
|
||||
import eu.darken.capod.common.settings.SettingsCategoryHeader
|
||||
import eu.darken.capod.common.settings.SettingsSliderItem
|
||||
import eu.darken.capod.common.settings.SettingsSwitchItem
|
||||
import eu.darken.capod.main.ui.overview.cards.AncModeSelector
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
|
||||
@Composable
|
||||
fun DeviceSettingsScreenHost(
|
||||
address: String,
|
||||
vm: DeviceSettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
ErrorEventHandler(vm)
|
||||
NavigationEventHandler(vm)
|
||||
|
||||
LaunchedEffect(address) { vm.initialize(address) }
|
||||
|
||||
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
|
||||
val currentState = state ?: return
|
||||
|
||||
DeviceSettingsScreen(
|
||||
state = currentState,
|
||||
onNavigateUp = { vm.navUp() },
|
||||
onAncModeChange = { vm.setAncMode(it) },
|
||||
onConversationalAwarenessChange = { vm.setConversationalAwareness(it) },
|
||||
onNcWithOneAirPodChange = { vm.setNcWithOneAirPod(it) },
|
||||
onPersonalizedVolumeChange = { vm.setPersonalizedVolume(it) },
|
||||
onToneVolumeChange = { vm.setToneVolume(it) },
|
||||
onAdaptiveAudioNoiseChange = { vm.setAdaptiveAudioNoise(it) },
|
||||
onPressSpeedChange = { vm.setPressSpeed(it) },
|
||||
onPressHoldDurationChange = { vm.setPressHoldDuration(it) },
|
||||
onVolumeSwipeChange = { vm.setVolumeSwipe(it) },
|
||||
onVolumeSwipeLengthChange = { vm.setVolumeSwipeLength(it) },
|
||||
onEndCallMuteMicChange = { muteMic, endCall -> vm.setEndCallMuteMic(muteMic, endCall) },
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DeviceSettingsScreen(
|
||||
state: DeviceSettingsViewModel.State,
|
||||
onNavigateUp: () -> Unit,
|
||||
onAncModeChange: (AapSetting.AncMode.Value) -> Unit = {},
|
||||
onConversationalAwarenessChange: (Boolean) -> Unit = {},
|
||||
onNcWithOneAirPodChange: (Boolean) -> Unit = {},
|
||||
onPersonalizedVolumeChange: (Boolean) -> Unit = {},
|
||||
onToneVolumeChange: (Int) -> Unit = {},
|
||||
onAdaptiveAudioNoiseChange: (Int) -> Unit = {},
|
||||
onPressSpeedChange: (AapSetting.PressSpeed.Value) -> Unit = {},
|
||||
onPressHoldDurationChange: (AapSetting.PressHoldDuration.Value) -> Unit = {},
|
||||
onVolumeSwipeChange: (Boolean) -> Unit = {},
|
||||
onVolumeSwipeLengthChange: (AapSetting.VolumeSwipeLength.Value) -> Unit = {},
|
||||
onEndCallMuteMicChange: (AapSetting.EndCallMuteMic.MuteMicMode, AapSetting.EndCallMuteMic.EndCallMode) -> Unit = { _, _ -> },
|
||||
) {
|
||||
val device = state.device
|
||||
val features = device?.model?.features
|
||||
val enabled = device?.isAapReady == true
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.device_settings_title),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateUp) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
) {
|
||||
// Device Info
|
||||
val deviceInfo = device?.deviceInfo
|
||||
if (deviceInfo != null) {
|
||||
item("device_info") {
|
||||
DeviceInfoCard(deviceInfo = deviceInfo)
|
||||
}
|
||||
}
|
||||
|
||||
// Sound section — only show when AAP is connected (settings come from AAP)
|
||||
if (features != null && device.isAapConnected) {
|
||||
item("sound_header") {
|
||||
SettingsCategoryHeader(text = stringResource(R.string.device_settings_category_sound_label))
|
||||
}
|
||||
|
||||
val ancMode = device.ancMode
|
||||
if (features.hasAncControl && ancMode != null) {
|
||||
item("anc_mode") {
|
||||
AncModeSelector(
|
||||
currentMode = ancMode.current,
|
||||
supportedModes = ancMode.supported,
|
||||
onModeSelected = onAncModeChange,
|
||||
pendingMode = device.pendingAncMode,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
enabled = enabled,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
val convAwareness = device.conversationalAwareness
|
||||
if (features.hasConversationAwareness && convAwareness != null) {
|
||||
item("conversation_awareness") {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val ncOneAirpod = device.ncWithOneAirPod
|
||||
if (features.hasNcOneAirpod && ncOneAirpod != null) {
|
||||
item("nc_one_airpod") {
|
||||
SettingsSwitchItem(
|
||||
icon = Icons.TwoTone.Headphones,
|
||||
title = stringResource(R.string.device_settings_nc_one_airpod_label),
|
||||
subtitle = stringResource(R.string.device_settings_nc_one_airpod_description),
|
||||
checked = ncOneAirpod.enabled,
|
||||
onCheckedChange = onNcWithOneAirPodChange,
|
||||
enabled = enabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val personalizedVol = device.personalizedVolume
|
||||
if (features.hasPersonalizedVolume && personalizedVol != null) {
|
||||
item("personalized_volume") {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val toneVol = device.toneVolume
|
||||
if (features.hasToneVolume && toneVol != null) {
|
||||
item("tone_volume") {
|
||||
ToneVolumeSlider(
|
||||
level = toneVol.level,
|
||||
onLevelChange = onToneVolumeChange,
|
||||
enabled = enabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val adaptiveNoise = device.adaptiveAudioNoise
|
||||
if (features.hasAdaptiveAudioNoise && adaptiveNoise != null) {
|
||||
item("adaptive_noise") {
|
||||
AdaptiveNoiseSlider(
|
||||
level = adaptiveNoise.level,
|
||||
onLevelChange = onAdaptiveAudioNoiseChange,
|
||||
enabled = enabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Controls section
|
||||
item("controls_header") {
|
||||
SettingsCategoryHeader(text = stringResource(R.string.device_settings_category_controls_label))
|
||||
}
|
||||
|
||||
val pressSpd = device.pressSpeed
|
||||
if (features.hasPressSpeed && pressSpd != null) {
|
||||
item("press_speed") {
|
||||
SegmentedSettingRow(
|
||||
icon = Icons.TwoTone.Speed,
|
||||
title = stringResource(R.string.device_settings_press_speed_label),
|
||||
subtitle = stringResource(R.string.device_settings_press_speed_description),
|
||||
options = listOf(
|
||||
stringResource(R.string.device_settings_press_speed_default) to AapSetting.PressSpeed.Value.DEFAULT,
|
||||
stringResource(R.string.device_settings_press_speed_slower) to AapSetting.PressSpeed.Value.SLOWER,
|
||||
stringResource(R.string.device_settings_press_speed_slowest) to AapSetting.PressSpeed.Value.SLOWEST,
|
||||
),
|
||||
selected = pressSpd.value,
|
||||
onSelected = onPressSpeedChange,
|
||||
enabled = enabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val pressHold = device.pressHoldDuration
|
||||
if (features.hasPressHoldDuration && pressHold != null) {
|
||||
item("press_hold") {
|
||||
SegmentedSettingRow(
|
||||
icon = Icons.TwoTone.Timer,
|
||||
title = stringResource(R.string.device_settings_press_hold_label),
|
||||
subtitle = stringResource(R.string.device_settings_press_hold_description),
|
||||
options = listOf(
|
||||
stringResource(R.string.device_settings_press_hold_default) to AapSetting.PressHoldDuration.Value.DEFAULT,
|
||||
stringResource(R.string.device_settings_press_hold_shorter) to AapSetting.PressHoldDuration.Value.SHORTER,
|
||||
stringResource(R.string.device_settings_press_hold_shortest) to AapSetting.PressHoldDuration.Value.SHORTEST,
|
||||
),
|
||||
selected = pressHold.value,
|
||||
onSelected = onPressHoldDurationChange,
|
||||
enabled = enabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val volSwipe = device.volumeSwipe
|
||||
if (features.hasVolumeSwipe && volSwipe != null) {
|
||||
item("volume_swipe") {
|
||||
SettingsSwitchItem(
|
||||
icon = Icons.TwoTone.Swipe,
|
||||
title = stringResource(R.string.device_settings_volume_swipe_label),
|
||||
subtitle = stringResource(R.string.device_settings_volume_swipe_description),
|
||||
checked = volSwipe.enabled,
|
||||
onCheckedChange = onVolumeSwipeChange,
|
||||
enabled = enabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val volSwipeLen = device.volumeSwipeLength
|
||||
if (features.hasVolumeSwipeLength && volSwipeLen != null) {
|
||||
item("volume_swipe_length") {
|
||||
SegmentedSettingRow(
|
||||
icon = Icons.TwoTone.Swipe,
|
||||
title = stringResource(R.string.device_settings_volume_swipe_length_label),
|
||||
subtitle = stringResource(R.string.device_settings_volume_swipe_length_description),
|
||||
options = listOf(
|
||||
stringResource(R.string.device_settings_volume_swipe_length_default) to AapSetting.VolumeSwipeLength.Value.DEFAULT,
|
||||
stringResource(R.string.device_settings_volume_swipe_length_longer) to AapSetting.VolumeSwipeLength.Value.LONGER,
|
||||
stringResource(R.string.device_settings_volume_swipe_length_longest) to AapSetting.VolumeSwipeLength.Value.LONGEST,
|
||||
),
|
||||
selected = volSwipeLen.value,
|
||||
onSelected = onVolumeSwipeLengthChange,
|
||||
enabled = enabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val endCallMuteMic = device.endCallMuteMic
|
||||
if (features.hasEndCallMuteMic && endCallMuteMic != null) {
|
||||
item("end_call_mute_mic") {
|
||||
EndCallMuteMicControl(
|
||||
current = endCallMuteMic,
|
||||
onChange = onEndCallMuteMicChange,
|
||||
enabled = enabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item("bottom_spacer") {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceInfoCard(deviceInfo: AapDeviceInfo) {
|
||||
ElevatedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
if (deviceInfo.name.isNotBlank()) {
|
||||
InfoRow(
|
||||
label = stringResource(R.string.device_settings_info_name_label),
|
||||
value = deviceInfo.name,
|
||||
)
|
||||
}
|
||||
if (deviceInfo.serialNumber.isNotBlank()) {
|
||||
InfoRow(
|
||||
label = stringResource(R.string.device_settings_info_serial_label),
|
||||
value = deviceInfo.serialNumber,
|
||||
)
|
||||
}
|
||||
if (deviceInfo.firmwareVersion.isNotBlank()) {
|
||||
InfoRow(
|
||||
label = stringResource(R.string.device_settings_info_firmware_label),
|
||||
value = deviceInfo.firmwareVersion,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.width(120.dp),
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
) {
|
||||
var sliderValue by remember(level) { mutableIntStateOf(level) }
|
||||
SettingsSliderItem(
|
||||
icon = Icons.TwoTone.GraphicEq,
|
||||
title = stringResource(R.string.device_settings_adaptive_noise_label),
|
||||
subtitle = stringResource(R.string.device_settings_adaptive_noise_description),
|
||||
value = sliderValue.toFloat(),
|
||||
onValueChange = { sliderValue = it.toInt() },
|
||||
onValueChangeFinished = { onLevelChange(sliderValue) },
|
||||
valueRange = 0f..100f,
|
||||
steps = 99,
|
||||
enabled = enabled,
|
||||
valueLabel = { "${it.toInt()}%" },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private 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,
|
||||
) {
|
||||
Column(modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EndCallMuteMicControl(
|
||||
current: AapSetting.EndCallMuteMic,
|
||||
onChange: (AapSetting.EndCallMuteMic.MuteMicMode, AapSetting.EndCallMuteMic.EndCallMode) -> Unit,
|
||||
enabled: Boolean,
|
||||
) {
|
||||
val optionASelected = current.endCall == AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS
|
||||
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.TouchApp,
|
||||
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 = stringResource(R.string.device_settings_end_call_mute_mic_label),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 1f else 0.5f),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.device_settings_end_call_mute_mic_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (enabled) 1f else 0.5f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
CallControlOption(
|
||||
title = stringResource(R.string.device_settings_end_call_mute_mic_option_a_title),
|
||||
subtitle = stringResource(R.string.device_settings_end_call_mute_mic_option_a_subtitle),
|
||||
selected = optionASelected,
|
||||
enabled = enabled,
|
||||
onClick = {
|
||||
onChange(
|
||||
AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS,
|
||||
AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
CallControlOption(
|
||||
title = stringResource(R.string.device_settings_end_call_mute_mic_option_b_title),
|
||||
subtitle = stringResource(R.string.device_settings_end_call_mute_mic_option_b_subtitle),
|
||||
selected = !optionASelected,
|
||||
enabled = enabled,
|
||||
onClick = {
|
||||
onChange(
|
||||
AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS,
|
||||
AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CallControlOption(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
selected: Boolean,
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
)
|
||||
Column(modifier = Modifier.padding(start = 4.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 1f else 0.5f),
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (enabled) 1f else 0.5f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package eu.darken.capod.main.ui.devicesettings
|
||||
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.uix.ViewModel4
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class DeviceSettingsViewModel @Inject constructor(
|
||||
dispatcherProvider: DispatcherProvider,
|
||||
private val deviceMonitor: DeviceMonitor,
|
||||
private val aapManager: AapConnectionManager,
|
||||
) : ViewModel4(dispatcherProvider) {
|
||||
|
||||
private val targetAddress = MutableStateFlow<BluetoothAddress?>(null)
|
||||
private var initialized = false
|
||||
|
||||
fun initialize(address: BluetoothAddress) {
|
||||
if (initialized && targetAddress.value == address) return
|
||||
initialized = true
|
||||
targetAddress.value = address
|
||||
}
|
||||
|
||||
val state = targetAddress.flatMapLatest { address ->
|
||||
if (address == null) return@flatMapLatest flowOf(State(device = null))
|
||||
deviceMonitor.devices.map { devices ->
|
||||
State(device = devices.firstOrNull { it.address == address })
|
||||
}
|
||||
}.asLiveState()
|
||||
|
||||
data class State(
|
||||
val device: PodDevice?,
|
||||
)
|
||||
|
||||
private fun send(command: AapCommand) {
|
||||
val address = targetAddress.value ?: return
|
||||
launch {
|
||||
try {
|
||||
aapManager.sendCommand(address, command)
|
||||
log(TAG) { "Sent $command to $address" }
|
||||
} catch (e: Exception) {
|
||||
log(TAG) { "Failed to send $command: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAncMode(mode: AapSetting.AncMode.Value) = send(AapCommand.SetAncMode(mode))
|
||||
|
||||
fun setConversationalAwareness(enabled: Boolean) = send(AapCommand.SetConversationalAwareness(enabled))
|
||||
|
||||
fun setNcWithOneAirPod(enabled: Boolean) = send(AapCommand.SetNcWithOneAirPod(enabled))
|
||||
|
||||
fun setPersonalizedVolume(enabled: Boolean) = send(AapCommand.SetPersonalizedVolume(enabled))
|
||||
|
||||
fun setToneVolume(level: Int) = send(AapCommand.SetToneVolume(level))
|
||||
|
||||
fun setAdaptiveAudioNoise(level: Int) = send(AapCommand.SetAdaptiveAudioNoise(level))
|
||||
|
||||
fun setPressSpeed(value: AapSetting.PressSpeed.Value) = send(AapCommand.SetPressSpeed(value))
|
||||
|
||||
fun setPressHoldDuration(value: AapSetting.PressHoldDuration.Value) = send(AapCommand.SetPressHoldDuration(value))
|
||||
|
||||
fun setVolumeSwipe(enabled: Boolean) = send(AapCommand.SetVolumeSwipe(enabled))
|
||||
|
||||
fun setVolumeSwipeLength(value: AapSetting.VolumeSwipeLength.Value) = send(AapCommand.SetVolumeSwipeLength(value))
|
||||
|
||||
fun setEndCallMuteMic(
|
||||
muteMic: AapSetting.EndCallMuteMic.MuteMicMode,
|
||||
endCall: AapSetting.EndCallMuteMic.EndCallMode,
|
||||
) = send(AapCommand.SetEndCallMuteMic(muteMic, endCall))
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("DeviceSettings", "VM")
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,7 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
||||
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
|
||||
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
|
||||
onConversationAwarenessChange = { device, enabled -> vm.setConversationalAwareness(device, enabled) },
|
||||
onDeviceSettings = { device -> vm.goToDeviceSettings(device) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -144,6 +145,7 @@ fun OverviewScreen(
|
||||
onToggleUnmatched: () -> Unit,
|
||||
onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> Unit = { _, _ -> },
|
||||
onConversationAwarenessChange: (PodDevice, Boolean) -> Unit = { _, _ -> },
|
||||
onDeviceSettings: (PodDevice) -> Unit = {},
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -234,6 +236,7 @@ fun OverviewScreen(
|
||||
now = state.now,
|
||||
onAncModeChange = { mode -> onAncModeChange(device, mode) },
|
||||
onConversationAwarenessChange = { enabled -> onConversationAwarenessChange(device, enabled) },
|
||||
onDeviceSettings = { onDeviceSettings(device) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -271,12 +274,13 @@ fun OverviewScreen(
|
||||
key = { "unmatched_${it.identifier?.toString() ?: it.hashCode()}" },
|
||||
) { device ->
|
||||
PodDeviceCard(
|
||||
device = device,
|
||||
showDebug = state.isDebugMode,
|
||||
now = state.now,
|
||||
onAncModeChange = { mode -> onAncModeChange(device, mode) },
|
||||
onConversationAwarenessChange = { enabled -> onConversationAwarenessChange(device, enabled) },
|
||||
)
|
||||
device = device,
|
||||
showDebug = state.isDebugMode,
|
||||
now = state.now,
|
||||
onAncModeChange = { mode -> onAncModeChange(device, mode) },
|
||||
onConversationAwarenessChange = { enabled -> onConversationAwarenessChange(device, enabled) },
|
||||
onDeviceSettings = { onDeviceSettings(device) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -292,14 +296,20 @@ private fun PodDeviceCard(
|
||||
now: Instant,
|
||||
onAncModeChange: (AapSetting.AncMode.Value) -> Unit,
|
||||
onConversationAwarenessChange: (Boolean) -> Unit = {},
|
||||
onDeviceSettings: (() -> Unit)? = null,
|
||||
) {
|
||||
when {
|
||||
device.hasDualPods -> DualPodsCard(
|
||||
device = device, showDebug = showDebug, now = now,
|
||||
onAncModeChange = onAncModeChange,
|
||||
onConversationAwarenessChange = onConversationAwarenessChange,
|
||||
onDeviceSettings = onDeviceSettings,
|
||||
)
|
||||
device.model != PodModel.UNKNOWN -> SinglePodsCard(
|
||||
device = device, showDebug = showDebug, now = now,
|
||||
onAncModeChange = onAncModeChange,
|
||||
onDeviceSettings = onDeviceSettings,
|
||||
)
|
||||
device.model != PodModel.UNKNOWN -> SinglePodsCard(device = device, showDebug = showDebug, now = now, onAncModeChange = onAncModeChange)
|
||||
else -> UnknownPodDeviceCard(device = device, showDebug = showDebug, now = now)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,11 @@ class OverviewViewModel @Inject constructor(
|
||||
navTo(Nav.Main.DeviceManager)
|
||||
}
|
||||
|
||||
fun goToDeviceSettings(device: PodDevice) {
|
||||
val address = device.address ?: return
|
||||
navTo(Nav.Main.DeviceSettings(address))
|
||||
}
|
||||
|
||||
fun onUpgrade() {
|
||||
navTo(Nav.Main.Upgrade)
|
||||
}
|
||||
|
||||
@@ -20,10 +20,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
||||
import androidx.compose.material.icons.twotone.GridView
|
||||
import androidx.compose.material.icons.twotone.Tune
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -65,6 +68,7 @@ fun DualPodsCard(
|
||||
now: Instant,
|
||||
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
|
||||
onConversationAwarenessChange: ((Boolean) -> Unit)? = null,
|
||||
onDeviceSettings: (() -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -126,6 +130,16 @@ fun DualPodsCard(
|
||||
isAapConnected = device.isAapConnected,
|
||||
isLive = device.isLive,
|
||||
)
|
||||
|
||||
if (device.address != null && onDeviceSettings != null) {
|
||||
IconButton(onClick = onDeviceSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Tune,
|
||||
contentDescription = stringResource(R.string.device_settings_open_cd),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
@@ -248,13 +248,16 @@ fun AncModeSelector(
|
||||
supportedModes: List<AapSetting.AncMode.Value>,
|
||||
onModeSelected: (AapSetting.AncMode.Value) -> Unit,
|
||||
pendingMode: AapSetting.AncMode.Value? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val displayMode = pendingMode ?: currentMode
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
SingleChoiceSegmentedButtonRow(modifier = modifier.fillMaxWidth()) {
|
||||
supportedModes.forEachIndexed { index, mode ->
|
||||
SegmentedButton(
|
||||
selected = mode == displayMode,
|
||||
onClick = { onModeSelected(mode) },
|
||||
enabled = enabled,
|
||||
shape = SegmentedButtonDefaults.itemShape(index, supportedModes.size),
|
||||
colors = if (pendingMode != null && mode == displayMode) {
|
||||
SegmentedButtonDefaults.colors(
|
||||
|
||||
@@ -21,9 +21,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
||||
import androidx.compose.material.icons.twotone.Hearing
|
||||
import androidx.compose.material.icons.twotone.Tune
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -59,6 +62,7 @@ fun SinglePodsCard(
|
||||
showDebug: Boolean,
|
||||
now: Instant,
|
||||
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
|
||||
onDeviceSettings: (() -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -123,6 +127,16 @@ fun SinglePodsCard(
|
||||
isAapConnected = device.isAapConnected,
|
||||
isLive = device.isLive,
|
||||
)
|
||||
|
||||
if (device.address != null && onDeviceSettings != null) {
|
||||
IconButton(onClick = onDeviceSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Tune,
|
||||
contentDescription = stringResource(R.string.device_settings_open_cd),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
@@ -7,6 +7,7 @@ import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.monitor.core.cache.CachedDeviceState
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
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
|
||||
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
||||
@@ -234,8 +235,25 @@ data class PodDevice(
|
||||
val adaptiveAudioNoise: AapSetting.AdaptiveAudioNoise?
|
||||
get() = aap?.setting()
|
||||
|
||||
val pressSpeed: AapSetting.PressSpeed?
|
||||
get() = aap?.setting()
|
||||
|
||||
val pressHoldDuration: AapSetting.PressHoldDuration?
|
||||
get() = aap?.setting()
|
||||
|
||||
val volumeSwipeLength: AapSetting.VolumeSwipeLength?
|
||||
get() = aap?.setting()
|
||||
|
||||
val endCallMuteMic: AapSetting.EndCallMuteMic?
|
||||
get() = aap?.setting()
|
||||
|
||||
val deviceInfo: AapDeviceInfo?
|
||||
get() = aap?.deviceInfo ?: cached?.deviceInfo
|
||||
|
||||
val isAapConnected: Boolean get() = aap != null
|
||||
|
||||
val isAapReady: Boolean get() = aap?.connectionState == AapPodState.ConnectionState.READY
|
||||
|
||||
val bleKeyState: BleKeyState
|
||||
get() {
|
||||
val applePod = ble as? ApplePods ?: return BleKeyState.NONE
|
||||
|
||||
@@ -2,6 +2,7 @@ package eu.darken.capod.monitor.core.cache
|
||||
|
||||
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.Instant
|
||||
@@ -19,9 +20,24 @@ data class CachedDeviceState(
|
||||
@SerialName("isRightCharging") val isRightCharging: Boolean? = null,
|
||||
@SerialName("isCaseCharging") val isCaseCharging: Boolean? = null,
|
||||
@SerialName("isHeadsetCharging") val isHeadsetCharging: Boolean? = null,
|
||||
@SerialName("deviceName") val deviceName: String? = null,
|
||||
@SerialName("serialNumber") val serialNumber: String? = null,
|
||||
@SerialName("firmwareVersion") val firmwareVersion: String? = null,
|
||||
@Serializable(with = InstantEpochMillisSerializer::class)
|
||||
@SerialName("lastSeenAt") val lastSeenAt: Instant,
|
||||
) {
|
||||
val deviceInfo: AapDeviceInfo?
|
||||
get() {
|
||||
if (deviceName == null && serialNumber == null && firmwareVersion == null) return null
|
||||
return AapDeviceInfo(
|
||||
name = deviceName ?: "",
|
||||
modelNumber = "",
|
||||
manufacturer = "",
|
||||
serialNumber = serialNumber ?: "",
|
||||
firmwareVersion = firmwareVersion ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class CachedBatterySlot(
|
||||
@SerialName("percent") val percent: Float,
|
||||
|
||||
+8
@@ -30,6 +30,8 @@ fun PodDevice.toCachedState(
|
||||
|
||||
if (liveLeft == null && liveRight == null && liveCase == null && liveHeadset == null) return null
|
||||
|
||||
val liveDeviceInfo = aap?.deviceInfo
|
||||
|
||||
val newState = CachedDeviceState(
|
||||
profileId = pid,
|
||||
model = model,
|
||||
@@ -42,6 +44,9 @@ fun PodDevice.toCachedState(
|
||||
isRightCharging = isRightPodCharging,
|
||||
isCaseCharging = isCaseCharging,
|
||||
isHeadsetCharging = isHeadsetBeingCharged,
|
||||
deviceName = liveDeviceInfo?.name ?: existing?.deviceName,
|
||||
serialNumber = liveDeviceInfo?.serialNumber ?: existing?.serialNumber,
|
||||
firmwareVersion = liveDeviceInfo?.firmwareVersion ?: existing?.firmwareVersion,
|
||||
lastSeenAt = seenLastAt ?: now,
|
||||
)
|
||||
|
||||
@@ -60,4 +65,7 @@ private fun hasStateChanged(old: CachedDeviceState, new: CachedDeviceState): Boo
|
||||
|| old.isRightCharging != new.isRightCharging
|
||||
|| old.isCaseCharging != new.isCaseCharging
|
||||
|| old.isHeadsetCharging != new.isHeadsetCharging
|
||||
|| old.deviceName != new.deviceName
|
||||
|| old.serialNumber != new.serialNumber
|
||||
|| old.firmwareVersion != new.firmwareVersion
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import kotlin.reflect.KClass
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapFramer
|
||||
@@ -159,9 +160,62 @@ internal class AapConnection(
|
||||
}
|
||||
}
|
||||
|
||||
applyOptimisticUpdate(currentState, command)
|
||||
sendRaw(command)
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistically update state for non-ANC settings so the UI reflects the change immediately.
|
||||
* ANC is handled separately in [send] due to ear-detection gating.
|
||||
* If the setting hasn't been reported by the device yet, skip (UI wouldn't show the toggle).
|
||||
*/
|
||||
private fun applyOptimisticUpdate(baseState: AapPodState, command: AapCommand) {
|
||||
val updated: Pair<KClass<out AapSetting>, AapSetting> = when (command) {
|
||||
is AapCommand.SetAncMode -> return // Handled in send()
|
||||
is AapCommand.SetConversationalAwareness -> {
|
||||
val cur = baseState.setting<AapSetting.ConversationalAwareness>() ?: return
|
||||
AapSetting.ConversationalAwareness::class to cur.copy(enabled = command.enabled)
|
||||
}
|
||||
is AapCommand.SetNcWithOneAirPod -> {
|
||||
val cur = baseState.setting<AapSetting.NcWithOneAirPod>() ?: return
|
||||
AapSetting.NcWithOneAirPod::class to cur.copy(enabled = command.enabled)
|
||||
}
|
||||
is AapCommand.SetVolumeSwipe -> {
|
||||
val cur = baseState.setting<AapSetting.VolumeSwipe>() ?: return
|
||||
AapSetting.VolumeSwipe::class to cur.copy(enabled = command.enabled)
|
||||
}
|
||||
is AapCommand.SetPersonalizedVolume -> {
|
||||
val cur = baseState.setting<AapSetting.PersonalizedVolume>() ?: return
|
||||
AapSetting.PersonalizedVolume::class to cur.copy(enabled = command.enabled)
|
||||
}
|
||||
is AapCommand.SetToneVolume -> {
|
||||
baseState.setting<AapSetting.ToneVolume>() ?: return
|
||||
AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = command.level)
|
||||
}
|
||||
is AapCommand.SetAdaptiveAudioNoise -> {
|
||||
baseState.setting<AapSetting.AdaptiveAudioNoise>() ?: return
|
||||
AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = command.level)
|
||||
}
|
||||
is AapCommand.SetPressSpeed -> {
|
||||
baseState.setting<AapSetting.PressSpeed>() ?: return
|
||||
AapSetting.PressSpeed::class to AapSetting.PressSpeed(value = command.value)
|
||||
}
|
||||
is AapCommand.SetPressHoldDuration -> {
|
||||
baseState.setting<AapSetting.PressHoldDuration>() ?: return
|
||||
AapSetting.PressHoldDuration::class to AapSetting.PressHoldDuration(value = command.value)
|
||||
}
|
||||
is AapCommand.SetVolumeSwipeLength -> {
|
||||
baseState.setting<AapSetting.VolumeSwipeLength>() ?: return
|
||||
AapSetting.VolumeSwipeLength::class to AapSetting.VolumeSwipeLength(value = command.value)
|
||||
}
|
||||
is AapCommand.SetEndCallMuteMic -> {
|
||||
baseState.setting<AapSetting.EndCallMuteMic>() ?: return
|
||||
AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(muteMic = command.muteMic, endCall = command.endCall)
|
||||
}
|
||||
}
|
||||
_state.value = baseState.withSetting(updated.first, updated.second).copy(lastMessageAt = Instant.now())
|
||||
}
|
||||
|
||||
private suspend fun sendRaw(command: AapCommand) {
|
||||
val bytes = profile.encodeCommand(command)
|
||||
writeMutex.withLock {
|
||||
|
||||
@@ -420,4 +420,45 @@
|
||||
<string name="support_contact_sent_title">Email sent?</string>
|
||||
<string name="support_contact_sent_message">Was the email sent successfully? The attached debug log will be deleted.</string>
|
||||
|
||||
<!-- Device Settings -->
|
||||
<string name="device_settings_title">Device Settings</string>
|
||||
<string name="device_settings_info_name_label">Name</string>
|
||||
<string name="device_settings_info_serial_label">Serial Number</string>
|
||||
<string name="device_settings_info_firmware_label">Firmware</string>
|
||||
<string name="device_settings_category_sound_label">Sound</string>
|
||||
<string name="device_settings_category_controls_label">Controls</string>
|
||||
<string name="device_settings_nc_one_airpod_label">Noise Cancellation with One AirPod</string>
|
||||
<string name="device_settings_nc_one_airpod_description">Keep noise cancellation active with only one AirPod</string>
|
||||
<string name="device_settings_personalized_volume_label">Personalized Volume</string>
|
||||
<string name="device_settings_personalized_volume_description">Adjust media volume based on environment</string>
|
||||
<string name="device_settings_conversation_awareness_description">Lower media volume and reduce noise when someone speaks to you</string>
|
||||
<string name="device_settings_tone_volume_label">Chime Volume</string>
|
||||
<string name="device_settings_tone_volume_description">Volume of confirmation sounds and ringtones</string>
|
||||
<string name="device_settings_adaptive_noise_label">Adaptive Audio Noise</string>
|
||||
<string name="device_settings_adaptive_noise_description">How much environmental noise is allowed through</string>
|
||||
<string name="device_settings_press_speed_label">Press Speed</string>
|
||||
<string name="device_settings_press_speed_description">How quickly you need to press for multi-press gestures</string>
|
||||
<string name="device_settings_press_speed_default">Default</string>
|
||||
<string name="device_settings_press_speed_slower">Slower</string>
|
||||
<string name="device_settings_press_speed_slowest">Slowest</string>
|
||||
<string name="device_settings_press_hold_label">Press and Hold Duration</string>
|
||||
<string name="device_settings_press_hold_description">How long to press and hold to switch noise control modes</string>
|
||||
<string name="device_settings_press_hold_default">Default</string>
|
||||
<string name="device_settings_press_hold_shorter">Shorter</string>
|
||||
<string name="device_settings_press_hold_shortest">Shortest</string>
|
||||
<string name="device_settings_volume_swipe_label">Volume Swipe</string>
|
||||
<string name="device_settings_volume_swipe_description">Swipe to adjust volume</string>
|
||||
<string name="device_settings_volume_swipe_length_label">Volume Swipe Length</string>
|
||||
<string name="device_settings_volume_swipe_length_description">How far to swipe on the stem to change volume</string>
|
||||
<string name="device_settings_volume_swipe_length_default">Default</string>
|
||||
<string name="device_settings_volume_swipe_length_longer">Longer</string>
|
||||
<string name="device_settings_volume_swipe_length_longest">Longest</string>
|
||||
<string name="device_settings_end_call_mute_mic_label">Call Controls</string>
|
||||
<string name="device_settings_end_call_mute_mic_description">Choose what pressing the stem does during calls</string>
|
||||
<string name="device_settings_end_call_mute_mic_option_a_title">Single press to end call</string>
|
||||
<string name="device_settings_end_call_mute_mic_option_a_subtitle">Double press to mute microphone</string>
|
||||
<string name="device_settings_end_call_mute_mic_option_b_title">Single press to mute microphone</string>
|
||||
<string name="device_settings_end_call_mute_mic_option_b_subtitle">Double press to end call</string>
|
||||
<string name="device_settings_open_cd">Open device settings</string>
|
||||
|
||||
</resources>
|
||||
Reference in New Issue
Block a user