feat(device-settings): Enrich device info card, extract shared ANC mode UI

Device info card: add model label with Apple model number, rename label to

Bluetooth Device Label, show cursive font on name mismatch with system BT

name, combine first/last seen on single row, add profile prefix to subtitle.

ANC mode: extract shared AncModeUi helpers for labels and icons, update

overview cards and device settings to use shared components.
This commit is contained in:
darken
2026-04-16 10:32:21 +02:00
committed by Matthias Urhahn
parent 32ff7e5bdd
commit 3ad732ff09
11 changed files with 392 additions and 206 deletions
+2 -1
View File
@@ -17,4 +17,5 @@
.kotlin
# Screenshot test reference images (ephemeral, regenerated on demand)
app/src/screenshotTest*/reference/
.codex
.codex
protocol-research/
@@ -0,0 +1,46 @@
package eu.darken.capod.main.ui.components
import android.content.Context
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.AutoAwesome
import androidx.compose.material.icons.twotone.DoNotDisturbOn
import androidx.compose.material.icons.twotone.Headphones
import androidx.compose.material.icons.twotone.Hearing
import androidx.compose.ui.graphics.vector.ImageVector
import eu.darken.capod.R
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
@StringRes
fun AapSetting.AncMode.Value.shortLabelRes(): Int = when (this) {
AapSetting.AncMode.Value.OFF -> R.string.anc_mode_off
AapSetting.AncMode.Value.ON -> R.string.anc_mode_on
AapSetting.AncMode.Value.TRANSPARENCY -> R.string.anc_mode_transparency
AapSetting.AncMode.Value.ADAPTIVE -> R.string.anc_mode_adaptive
}
fun AapSetting.AncMode.Value.shortLabel(context: Context): String = context.getString(shortLabelRes())
fun AapSetting.AncMode.Value.icon(): ImageVector = when (this) {
AapSetting.AncMode.Value.OFF -> Icons.TwoTone.DoNotDisturbOn
AapSetting.AncMode.Value.ON -> Icons.TwoTone.Headphones
AapSetting.AncMode.Value.TRANSPARENCY -> Icons.TwoTone.Hearing
AapSetting.AncMode.Value.ADAPTIVE -> Icons.TwoTone.AutoAwesome
}
fun AapSetting.AncMode.Value.cycleBit(): Int = when (this) {
AapSetting.AncMode.Value.OFF -> 0x01
AapSetting.AncMode.Value.ON -> 0x02
AapSetting.AncMode.Value.TRANSPARENCY -> 0x04
AapSetting.AncMode.Value.ADAPTIVE -> 0x08
}
fun visibleAncModes(
supportedModes: List<AapSetting.AncMode.Value>,
currentMode: AapSetting.AncMode.Value,
cycleMask: Int?,
allowOffEnabled: Boolean,
): List<AapSetting.AncMode.Value> = supportedModes.filter { mode ->
val inCycle = cycleMask?.let { (it and mode.cycleBit()) != 0 } ?: mode != AapSetting.AncMode.Value.OFF
inCycle || (mode == AapSetting.AncMode.Value.OFF && allowOffEnabled) || mode == currentMode
}
@@ -34,6 +34,8 @@ import androidx.compose.material.icons.twotone.Swipe
import androidx.compose.material.icons.twotone.Timer
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
@@ -86,8 +88,11 @@ 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.DeviceInfoCard
import eu.darken.capod.main.ui.devicesettings.cards.buildModelLabel
import eu.darken.capod.main.ui.devicesettings.cards.NotConnectedCard
import eu.darken.capod.main.ui.devicesettings.components.NoiseControlCombined
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
@@ -160,7 +165,6 @@ fun DeviceSettingsScreenHost(
onEndCallMuteMicChange = { muteMic, endCall -> vm.setEndCallMuteMic(muteMic, endCall) },
onMicrophoneModeChange = { vm.setMicrophoneMode(it) },
onListeningModeCycleChange = { vm.setListeningModeCycle(it) },
onAllowOffOptionChange = { vm.setAllowOffOption(it) },
onOffVisibilityChange = { enabled, mask -> vm.setListeningModeOffVisibility(enabled, mask) },
onSleepDetectionChange = { vm.setSleepDetection(it) },
onDeviceNameChange = { vm.setDeviceName(it) },
@@ -197,7 +201,6 @@ fun DeviceSettingsScreen(
onEndCallMuteMicChange: (AapSetting.EndCallMuteMic.MuteMicMode, AapSetting.EndCallMuteMic.EndCallMode) -> Unit = { _, _ -> },
onMicrophoneModeChange: (AapSetting.MicrophoneMode.Mode) -> Unit = {},
onListeningModeCycleChange: (Int) -> Unit = {},
onAllowOffOptionChange: (Boolean) -> Unit = {},
onOffVisibilityChange: (enabled: Boolean, currentCycleMask: Int) -> Unit = { _, _ -> },
onSleepDetectionChange: (Boolean) -> Unit = {},
onDeviceNameChange: (String) -> Unit = {},
@@ -214,6 +217,7 @@ fun DeviceSettingsScreen(
onFixMonitorMode: () -> Unit = {},
onOpenIssueTracker: () -> Unit = {},
) {
val context = LocalContext.current
val device = state.device
val features = device?.model?.features
val enabled = device?.isAapReady == true
@@ -221,6 +225,7 @@ fun DeviceSettingsScreen(
val reactions = state.reactions
var showAutoConnectConditionDialog by remember { mutableStateOf(false) }
var showListeningModeCycleDialog by rememberSaveable { mutableStateOf(false) }
Scaffold(
topBar = {
@@ -235,7 +240,7 @@ fun DeviceSettingsScreen(
val profileName = device?.label
if (profileName != null) {
Text(
text = profileName,
text = stringResource(R.string.device_settings_subtitle_profile_prefix, profileName),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -273,6 +278,8 @@ fun DeviceSettingsScreen(
} else null
DeviceInfoCard(
deviceInfo = device.deviceInfo,
modelLabel = buildModelLabel(device),
systemBluetoothName = state.systemBluetoothName,
connectionStateLabel = stateDetection?.state?.getLabel(context),
lastSeen = device.lastSeenFormatted(state.now),
firstSeen = firstSeen,
@@ -467,28 +474,76 @@ fun DeviceSettingsScreen(
val cycleMask = if (features.hasListeningModeCycle) {
(device.listeningModeCycle ?: AapSetting.ListeningModeCycle(modeMask = 0x0E)).modeMask
} else null
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
}
item("noise_control_section") {
SettingsSection(title = stringResource(R.string.device_settings_noise_control_label)) {
NoiseControlCombined(
NoiseControlCurrentModeControl(
currentMode = ancMode.current,
pendingMode = device.pendingAncMode,
supportedModes = ancMode.supported,
onModeSelected = onAncModeChange,
cycleMask = if (isPro) cycleMask else null,
onCycleMaskChange = onListeningModeCycleChange,
onAllowOffChange = onAllowOffOptionChange,
onOffVisibilityChange = onOffVisibilityChange,
enabled = enabled,
)
val hasNoiseExtras = (features.hasAdaptiveAudioNoise && adaptiveNoise != null) ||
(!isPro && features.hasListeningModeCycle)
features.hasListeningModeCycle
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) {
showListeningModeCycleDialog = 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,
@@ -497,15 +552,6 @@ fun DeviceSettingsScreen(
isAdaptiveMode = ancMode.current == AapSetting.AncMode.Value.ADAPTIVE,
)
}
if (!isPro && features.hasListeningModeCycle) {
SettingsBaseItem(
icon = Icons.TwoTone.Loop,
title = stringResource(R.string.device_settings_listening_mode_cycle_label),
subtitle = stringResource(R.string.device_settings_listening_mode_cycle_description),
onClick = onUpgrade,
requiresUpgrade = true,
)
}
}
}
}
@@ -713,6 +759,32 @@ fun DeviceSettingsScreen(
}
}
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 = { showListeningModeCycleDialog = false },
)
}
}
if (showAutoConnectConditionDialog && device != null) {
AutoConnectConditionDialog(
current = reactions.autoConnectCondition,
@@ -736,6 +808,135 @@ private fun ReactionsDivider() {
)
}
@Composable
private fun NoiseControlCurrentModeControl(
currentMode: AapSetting.AncMode.Value,
pendingMode: AapSetting.AncMode.Value?,
supportedModes: List<AapSetting.AncMode.Value>,
onModeSelected: (AapSetting.AncMode.Value) -> Unit,
enabled: Boolean,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerLow,
shape = RoundedCornerShape(16.dp),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp)) {
SettingsCompoundHeader(
icon = Icons.TwoTone.Headphones,
title = stringResource(R.string.device_settings_noise_control_current_mode_label),
subtitle = null,
enabled = enabled,
)
Spacer(modifier = Modifier.height(12.dp))
AncModeSelector(
currentMode = currentMode,
supportedModes = supportedModes,
onModeSelected = onModeSelected,
pendingMode = pendingMode,
enabled = enabled,
)
}
}
}
@Composable
private fun ListeningModeCycleDialog(
supportedModes: List<AapSetting.AncMode.Value>,
currentCycleMask: Int,
onSave: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val supportedMask = supportedModes.fold(0) { mask, mode -> mask or cycleBit(mode) }
var draftMask by remember(currentCycleMask, supportedMask) {
mutableIntStateOf(currentCycleMask and supportedMask)
}
val selectedCount = Integer.bitCount(draftMask and supportedMask)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(R.string.device_settings_listening_mode_cycle_dialog_title)) },
text = {
Column {
Text(
text = stringResource(R.string.device_settings_listening_mode_cycle_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(16.dp))
Column(modifier = Modifier.selectableGroup()) {
supportedModes.forEach { mode ->
val bit = cycleBit(mode)
val isSelected = (draftMask and bit) != 0
val canToggle = !isSelected || selectedCount > 2
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
enabled = canToggle,
role = Role.Checkbox,
onClick = {
draftMask = if (isSelected) {
draftMask and bit.inv()
} else {
draftMask or bit
}
},
)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = isSelected,
onCheckedChange = null,
enabled = canToggle,
)
Icon(
imageVector = mode.icon(),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (canToggle) 1f else 0.5f),
modifier = Modifier
.padding(start = 12.dp)
.align(Alignment.CenterVertically),
)
Text(
text = mode.listeningModeCycleDialogLabel(context),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (canToggle) 1f else 0.5f),
modifier = Modifier.padding(start = 12.dp),
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.device_settings_listening_mode_cycle_minimum),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
TextButton(
onClick = {
onSave(draftMask)
onDismiss()
},
enabled = selectedCount >= 2,
) {
Text(text = stringResource(R.string.general_save_action))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(R.string.general_cancel_action))
}
},
)
}
@Composable
private fun ToneVolumeSlider(
level: Int,
@@ -1053,6 +1254,30 @@ private fun ConnectedDevicesList(
}
}
private fun cycleBit(mode: AapSetting.AncMode.Value): Int = when (mode) {
AapSetting.AncMode.Value.OFF -> 0x01
AapSetting.AncMode.Value.ON -> 0x02
AapSetting.AncMode.Value.TRANSPARENCY -> 0x04
AapSetting.AncMode.Value.ADAPTIVE -> 0x08
}
private fun listeningModeCycleSummary(
context: android.content.Context,
supportedModes: List<AapSetting.AncMode.Value>,
cycleMask: Int,
): String = supportedModes
.filter { mode -> (cycleMask and cycleBit(mode)) != 0 }
.joinToString(separator = "") { it.shortLabel(context) }
private fun AapSetting.AncMode.Value.listeningModeCycleDialogLabel(
context: android.content.Context,
): String = when (this) {
AapSetting.AncMode.Value.OFF -> context.getString(R.string.device_settings_allow_off_label)
AapSetting.AncMode.Value.ON -> context.getString(R.string.device_settings_listening_mode_cycle_anc)
AapSetting.AncMode.Value.TRANSPARENCY -> context.getString(R.string.device_settings_listening_mode_cycle_transparency)
AapSetting.AncMode.Value.ADAPTIVE -> context.getString(R.string.device_settings_listening_mode_cycle_adaptive)
}
private fun previewFullState(isPro: Boolean) = DeviceSettingsViewModel.State(
device = PodDevice(
profileId = "preview",
@@ -1160,4 +1385,3 @@ private fun DeviceSettingsCachedOnlyPreview() = PreviewWrapper {
onNavigateUp = {},
)
}
@@ -28,6 +28,8 @@ import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.profiles.core.ReactionConfig
import eu.darken.capod.profiles.core.ProfileId
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import eu.darken.capod.reaction.core.stem.StemAction
import eu.darken.capod.reaction.core.stem.StemActionSettings
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.channelFlow
@@ -50,6 +52,7 @@ class DeviceSettingsViewModel @Inject constructor(
private val bluetoothManager: BluetoothManager2,
private val profilesRepo: DeviceProfilesRepo,
private val generalSettings: GeneralSettings,
private val stemActionSettings: StemActionSettings,
private val timeSource: TimeSource,
private val webpageTool: WebpageTool,
) : ViewModel4(dispatcherProvider) {
@@ -91,6 +94,8 @@ class DeviceSettingsViewModel @Inject constructor(
// Seed this branch so the screen can render immediately after navigation.
bluetoothManager.connectedDevices.onStart { emit(emptyList()) },
generalSettings.monitorMode.flow,
stemActionSettings.leftLong.flow,
stemActionSettings.rightLong.flow,
) { args ->
val device = args[1] as PodDevice?
val upgrade = args[2] as eu.darken.capod.common.upgrade.UpgradeRepo.Info
@@ -98,7 +103,16 @@ class DeviceSettingsViewModel @Inject constructor(
@Suppress("UNCHECKED_CAST")
val connectedDevices = args[4] as Collection<eu.darken.capod.common.bluetooth.BluetoothDevice2>
val monitorMode = args[5] as MonitorMode
val leftLong = args[6] as StemAction
val rightLong = args[7] as StemAction
val connectedAddresses = connectedDevices.map { it.address }.toSet()
val systemBtName = device?.address?.let { addr ->
try {
bluetoothManager.bondedDevices().first().firstOrNull { it.address == addr }?.name
} catch (_: Exception) {
null
}
}
State(
device = device,
now = timeSource.now(),
@@ -107,6 +121,8 @@ class DeviceSettingsViewModel @Inject constructor(
isForceConnecting = forcing,
isClassicallyConnected = device?.address?.let { it in connectedAddresses } == true,
monitorMode = monitorMode,
systemBluetoothName = systemBtName,
hasCustomLongPressStemAction = leftLong != StemAction.NONE || rightLong != StemAction.NONE,
)
}
}.asLiveState()
@@ -130,6 +146,8 @@ class DeviceSettingsViewModel @Inject constructor(
val isForceConnecting: Boolean = false,
val isClassicallyConnected: Boolean = false,
val monitorMode: MonitorMode = MonitorMode.AUTOMATIC,
val systemBluetoothName: String? = null,
val hasCustomLongPressStemAction: Boolean = false,
) {
val reactions: ReactionConfig get() = device?.reactions ?: ReactionConfig()
}
@@ -233,14 +251,12 @@ class DeviceSettingsViewModel @Inject constructor(
fun setListeningModeCycle(modeMask: Int) = sendProGated(AapCommand.SetListeningModeCycle(modeMask))
fun setAllowOffOption(enabled: Boolean) = sendProGated(AapCommand.SetAllowOffOption(enabled))
fun setListeningModeOffVisibility(enabled: Boolean, currentCycleMask: Int) = launch {
if (!upgradeRepo.isPro()) {
navTo(Nav.Main.Upgrade)
return@launch
}
// Keep in sync with cycleBits[OFF] in DeviceSettingsScreen.NoiseControlCombined
// Keep in sync with cycleBit(OFF) in DeviceSettingsScreen.
val offBit = 0x01
val newMask = if (enabled) currentCycleMask or offBit else currentCycleMask and offBit.inv()
if (sendInternal(AapCommand.SetListeningModeCycle(newMask))) {
@@ -1,150 +0,0 @@
package eu.darken.capod.main.ui.devicesettings.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Visibility
import androidx.compose.material.icons.twotone.VisibilityOff
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.runtime.Composable
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
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.SettingsSection
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
@Composable
internal fun NoiseControlCombined(
currentMode: AapSetting.AncMode.Value,
pendingMode: AapSetting.AncMode.Value?,
supportedModes: List<AapSetting.AncMode.Value>,
onModeSelected: (AapSetting.AncMode.Value) -> Unit,
cycleMask: Int?,
onCycleMaskChange: (Int) -> Unit,
onAllowOffChange: (Boolean) -> Unit = {},
onOffVisibilityChange: (enabled: Boolean, currentCycleMask: Int) -> Unit = { _, _ -> },
enabled: Boolean,
) {
val displayMode = pendingMode ?: currentMode
val cycleBits = mapOf(
AapSetting.AncMode.Value.OFF to 0x01,
AapSetting.AncMode.Value.ON to 0x02,
AapSetting.AncMode.Value.TRANSPARENCY to 0x04,
AapSetting.AncMode.Value.ADAPTIVE to 0x08,
)
Column(modifier = Modifier.padding(vertical = 4.dp, horizontal = 4.dp)) {
for (mode in supportedModes) {
val isSelected = mode == displayMode
val bit = cycleBits[mode] ?: continue
val inCycle = cycleMask?.let { (it and bit) != 0 }
val cycleCount = cycleMask?.let { Integer.bitCount(it and 0x0F) } ?: 0
val canRemoveFromCycle = inCycle != true || cycleCount > 2
Row(
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Visibility toggle — independent click target
if (cycleMask != null) {
IconButton(
onClick = {
val isOff = mode == AapSetting.AncMode.Value.OFF
if (isOff) {
onOffVisibilityChange(inCycle != true, cycleMask)
} else if (inCycle == true && canRemoveFromCycle) {
onCycleMaskChange(cycleMask xor bit)
} else if (inCycle != true) {
onCycleMaskChange(cycleMask or bit)
}
},
enabled = enabled && (inCycle != true || canRemoveFromCycle),
) {
Icon(
imageVector = if (inCycle == true) Icons.TwoTone.Visibility else Icons.TwoTone.VisibilityOff,
contentDescription = null,
tint = if (inCycle == true) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
},
)
}
}
// Mode selection — label + radio as one click target
Row(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clickable(enabled = enabled) { onModeSelected(mode) }
.padding(start = if (cycleMask == null) 16.dp else 0.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = mode.label(),
style = MaterialTheme.typography.bodyLarge,
color = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 1f else 0.5f)
},
fontWeight = if (isSelected) FontWeight.Bold else null,
modifier = Modifier.weight(1f),
)
RadioButton(
selected = isSelected,
onClick = null,
enabled = enabled,
modifier = Modifier.padding(16.dp),
)
}
}
}
}
}
@Composable
private fun AapSetting.AncMode.Value.label(): String = when (this) {
AapSetting.AncMode.Value.OFF -> stringResource(R.string.device_settings_listening_mode_cycle_off)
AapSetting.AncMode.Value.ON -> stringResource(R.string.device_settings_listening_mode_cycle_anc)
AapSetting.AncMode.Value.TRANSPARENCY -> stringResource(R.string.device_settings_listening_mode_cycle_transparency)
AapSetting.AncMode.Value.ADAPTIVE -> stringResource(R.string.device_settings_listening_mode_cycle_adaptive)
}
private val ALL_MODES = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.TRANSPARENCY,
AapSetting.AncMode.Value.ADAPTIVE,
)
@Preview2
@Composable
private fun NoiseControlCombinedProPreview() = PreviewWrapper {
NoiseControlCombined(
currentMode = AapSetting.AncMode.Value.ADAPTIVE,
pendingMode = null,
supportedModes = ALL_MODES,
onModeSelected = {},
cycleMask = 0x0E,
onCycleMaskChange = {},
enabled = true,
)
}
@@ -43,6 +43,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.main.ui.components.visibleAncModes
import eu.darken.capod.main.ui.overview.cards.components.AncModeSelector
import eu.darken.capod.main.ui.overview.cards.components.BatteryCapsule
import eu.darken.capod.main.ui.overview.cards.components.DebugSection
@@ -236,17 +237,12 @@ fun DualPodsCard(
val ancMode = device.ancMode
if (device.isAapConnected && device.hasAncControl && ancMode != null) {
Spacer(modifier = Modifier.height(8.dp))
val cycleMask = (device.listeningModeCycle?.modeMask ?: 0x0E)
val cycleBits = mapOf(
AapSetting.AncMode.Value.OFF to 0x01,
AapSetting.AncMode.Value.ON to 0x02,
AapSetting.AncMode.Value.TRANSPARENCY to 0x04,
AapSetting.AncMode.Value.ADAPTIVE to 0x08,
val visibleModes = visibleAncModes(
supportedModes = ancMode.supported,
currentMode = ancMode.current,
cycleMask = device.listeningModeCycle?.modeMask ?: 0x0E,
allowOffEnabled = device.allowOffOption?.enabled == true,
)
val visibleModes = ancMode.supported.filter { mode ->
val bit = cycleBits[mode] ?: return@filter true
(cycleMask and bit) != 0
}
AncModeSelector(
currentMode = ancMode.current,
supportedModes = visibleModes,
@@ -43,6 +43,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.main.ui.components.visibleAncModes
import eu.darken.capod.main.ui.overview.cards.components.AncModeSelector
import eu.darken.capod.main.ui.overview.cards.components.DebugSection
import eu.darken.capod.main.ui.overview.cards.components.DeviceConnectionBadge
@@ -259,17 +260,12 @@ fun SinglePodsCard(
val ancMode = device.ancMode
if (device.isAapConnected && device.hasAncControl && ancMode != null) {
Spacer(modifier = Modifier.height(12.dp))
val cycleMask = (device.listeningModeCycle?.modeMask ?: 0x0E)
val cycleBits = mapOf(
AapSetting.AncMode.Value.OFF to 0x01,
AapSetting.AncMode.Value.ON to 0x02,
AapSetting.AncMode.Value.TRANSPARENCY to 0x04,
AapSetting.AncMode.Value.ADAPTIVE to 0x08,
val visibleModes = visibleAncModes(
supportedModes = ancMode.supported,
currentMode = ancMode.current,
cycleMask = device.listeningModeCycle?.modeMask ?: 0x0E,
allowOffEnabled = device.allowOffOption?.enabled == true,
)
val visibleModes = ancMode.supported.filter { mode ->
val bit = cycleBits[mode] ?: return@filter true
(cycleMask and bit) != 0
}
AncModeSelector(
currentMode = ancMode.current,
supportedModes = visibleModes,
@@ -1,19 +1,32 @@
package eu.darken.capod.main.ui.overview.cards.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
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.components.icon
import eu.darken.capod.main.ui.components.shortLabelRes
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
@Composable
@@ -34,6 +47,7 @@ fun AncModeSelector(
onClick = { onModeSelected(mode) },
enabled = enabled,
shape = SegmentedButtonDefaults.itemShape(index, supportedModes.size),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
colors = if (pendingMode != null && mode == displayMode) {
SegmentedButtonDefaults.colors(
activeContainerColor = MaterialTheme.colorScheme.secondaryContainer,
@@ -42,17 +56,37 @@ fun AncModeSelector(
} else {
SegmentedButtonDefaults.colors()
},
icon = {},
label = {
Text(
text = when (mode) {
AapSetting.AncMode.Value.OFF -> stringResource(R.string.anc_mode_off)
AapSetting.AncMode.Value.ON -> stringResource(R.string.anc_mode_on)
AapSetting.AncMode.Value.TRANSPARENCY -> stringResource(R.string.anc_mode_transparency)
AapSetting.AncMode.Value.ADAPTIVE -> stringResource(R.string.anc_mode_adaptive)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val isSelected = mode == displayMode
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.padding(
start = if (index == 0) 6.dp else 0.dp,
end = if (index == supportedModes.lastIndex) 6.dp else 0.dp,
top = 4.dp,
bottom = 4.dp,
),
) {
Icon(
imageVector = mode.icon(),
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Text(
text = stringResource(mode.shortLabelRes()),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
textDecoration = if (isSelected) TextDecoration.Underline else TextDecoration.None,
modifier = Modifier.fillMaxWidth(),
)
}
},
)
}
@@ -550,6 +550,7 @@ internal class AapConnection(
0x0017, // HID/service descriptors
0x002B, // Session metadata / event history
0x004E, // Unknown (all-zero payload)
0x0052, // ANC mode change status/rejection
0x0055, // Audio/session state
0x0057, // Connection lifecycle
)
@@ -363,8 +363,8 @@ class DefaultAapDeviceProfile(
if (message.commandType != CMD_DEVICE_INFO) return null
if (message.payload.size < 10) return null
// Device info payload contains null-terminated ASCII strings
// Format: [length prefix] [strings...]
// Device info payload contains null-terminated UTF-8 strings
// Format: [binary header] [NUL-delimited strings...]
val strings = parseNullTerminatedStrings(message.payload)
if (strings.size < 4) return null
@@ -12,6 +12,8 @@ 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.profiles.core.DeviceProfilesRepo
import eu.darken.capod.reaction.core.stem.StemAction
import eu.darken.capod.reaction.core.stem.StemActionSettings
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.Called
@@ -60,6 +62,9 @@ class DeviceSettingsViewModelTest : BaseTest() {
private lateinit var profilesRepo: DeviceProfilesRepo
private lateinit var generalSettings: GeneralSettings
private lateinit var fakeMonitorMode: FakeDataStoreValue<MonitorMode>
private lateinit var stemActionSettings: StemActionSettings
private lateinit var fakeLeftLongStemAction: FakeDataStoreValue<StemAction>
private lateinit var fakeRightLongStemAction: FakeDataStoreValue<StemAction>
private val timeSource: TimeSource = TestTimeSource()
private lateinit var devicesFlow: MutableStateFlow<List<PodDevice>>
@@ -102,6 +107,12 @@ class DeviceSettingsViewModelTest : BaseTest() {
generalSettings = mockk<GeneralSettings>().also {
every { it.monitorMode } returns fakeMonitorMode.mock
}
fakeLeftLongStemAction = FakeDataStoreValue(StemAction.NONE)
fakeRightLongStemAction = FakeDataStoreValue(StemAction.NONE)
stemActionSettings = mockk<StemActionSettings>().also {
every { it.leftLong } returns fakeLeftLongStemAction.mock
every { it.rightLong } returns fakeRightLongStemAction.mock
}
}
@AfterEach
@@ -119,6 +130,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
bluetoothManager = bluetoothManager,
profilesRepo = profilesRepo,
generalSettings = generalSettings,
stemActionSettings = stemActionSettings,
timeSource = timeSource,
webpageTool = mockk(relaxed = true),
).also { vm = it }
@@ -324,4 +336,14 @@ class DeviceSettingsViewModelTest : BaseTest() {
state.device?.address shouldBe testAddress
state.isClassicallyConnected shouldBe false
}
@Test
fun `hasCustomLongPressStemAction is true when either long-press action is assigned`() = runVmTest {
fakeLeftLongStemAction.value = StemAction.PLAY_PAUSE
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first().hasCustomLongPressStemAction shouldBe true
}
}