mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
+2
-1
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+19
-3
@@ -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))) {
|
||||
|
||||
-150
@@ -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,
|
||||
|
||||
+44
-10
@@ -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
|
||||
)
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
+22
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user