From 33c8fe76a60f3b7edadcb23b22b1f8922134f966 Mon Sep 17 00:00:00 2001 From: darken Date: Sat, 4 Apr 2026 11:05:59 +0200 Subject: [PATCH] fix(aap): Polish noise control UI and fix ANC mode switching Combine ANC mode selector with listening mode cycle into a card with eye icon visibility toggles and radio button mode selection. Send AllowOff command when toggling OFF visibility. Fix ANC resend logic fighting rapid mode changes by cancelling pending resend jobs on new commands. Reorder settings into Noise Control, Sound, Controls, General, and Connections sections. --- .../ui/devicesettings/DeviceSettingsScreen.kt | 285 ++++++++++-------- .../pods/core/apple/aap/AapConnection.kt | 2 + app/src/main/res/values/strings.xml | 3 +- 3 files changed, 163 insertions(+), 127 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt index abec0725..465546a8 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt @@ -1,11 +1,12 @@ package eu.darken.capod.main.ui.devicesettings +import androidx.compose.foundation.Canvas import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -13,24 +14,21 @@ 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.DevicesOther +import androidx.compose.material.icons.twotone.Edit 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.foundation.layout.width -import androidx.compose.material.icons.twotone.Check -import androidx.compose.material.icons.twotone.DevicesOther -import androidx.compose.material.icons.twotone.Edit import androidx.compose.material.icons.twotone.Mic import androidx.compose.material.icons.twotone.Nightlight import androidx.compose.material.icons.twotone.NotificationsActive -import androidx.compose.material.icons.twotone.TouchApp +import androidx.compose.material.icons.twotone.Speed import androidx.compose.material.icons.twotone.Stars +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.Visibility import androidx.compose.material.icons.twotone.VisibilityOff -import androidx.compose.material.icons.twotone.Tune import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api @@ -51,10 +49,10 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -63,8 +61,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import eu.darken.capod.R import eu.darken.capod.common.compose.Preview2 import eu.darken.capod.common.compose.PreviewWrapper -import eu.darken.capod.common.compose.preview.MockPodDataProvider import eu.darken.capod.common.compose.preview.MOCK_NOW +import eu.darken.capod.common.compose.preview.MockPodDataProvider import eu.darken.capod.common.error.ErrorEventHandler import eu.darken.capod.common.navigation.NavigationEventHandler import eu.darken.capod.common.settings.SettingsBaseItem @@ -72,11 +70,10 @@ import eu.darken.capod.common.settings.SettingsCategoryHeader import eu.darken.capod.common.settings.SettingsPreferenceItem 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.pods.core.apple.aap.AapPodState import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.monitor.core.firstSeenFormatted import eu.darken.capod.monitor.core.lastSeenFormatted +import eu.darken.capod.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.devices.HasStateDetection @@ -193,7 +190,9 @@ fun DeviceSettingsScreen( val stateDetection = device.ble as? HasStateDetection val seenFirst = device.seenFirstAt val seenLast = device.seenLastAt - val firstSeen = if (seenFirst != null && seenLast != null && Duration.between(seenFirst, seenLast).toMinutes() >= 1) { + val firstSeen = if (seenFirst != null && seenLast != null && Duration.between(seenFirst, seenLast) + .toMinutes() >= 1 + ) { device.firstSeenFormatted(state.now) } else null DeviceInfoCard( @@ -207,14 +206,16 @@ fun DeviceSettingsScreen( } } - // Sound section — only show when AAP is connected (settings come from AAP) + // Settings — only show when AAP is connected if (features != null && device.isAapConnected) { - item("sound_header") { - SettingsCategoryHeader(text = stringResource(R.string.device_settings_category_sound_label)) - } + // ── Noise Control ──────────────────────────── val ancMode = device.ancMode if (features.hasAncControl && ancMode != null) { + item("noise_control_header") { + SettingsCategoryHeader(text = stringResource(R.string.device_settings_noise_control_label)) + } + val cycleMask = if (features.hasListeningModeCycle) { (device.listeningModeCycle ?: AapSetting.ListeningModeCycle(modeMask = 0x0E)).modeMask } else null @@ -227,40 +228,60 @@ fun DeviceSettingsScreen( onModeSelected = onAncModeChange, cycleMask = cycleMask, onCycleMaskChange = onListeningModeCycleChange, + onAllowOffChange = onAllowOffOptionChange, enabled = enabled, ) } - } - 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 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 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 adaptiveNoise = device.adaptiveAudioNoise + if (features.hasAdaptiveAudioNoise && adaptiveNoise != null) { + item("adaptive_noise") { + AdaptiveNoiseSlider( + level = adaptiveNoise.level, + onLevelChange = onAdaptiveAudioNoiseChange, + enabled = enabled, + ) + } } } + // ── Sound ──────────────────────────────────── val personalizedVol = device.personalizedVolume + val toneVol = device.toneVolume + if ((features.hasPersonalizedVolume && personalizedVol != null) || (features.hasToneVolume && toneVol != null)) { + item("sound_header") { + SettingsCategoryHeader(text = stringResource(R.string.device_settings_category_sound_label)) + } + } + if (features.hasPersonalizedVolume && personalizedVol != null) { item("personalized_volume") { SettingsSwitchItem( @@ -274,7 +295,6 @@ fun DeviceSettingsScreen( } } - val toneVol = device.toneVolume if (features.hasToneVolume && toneVol != null) { item("tone_volume") { ToneVolumeSlider( @@ -285,18 +305,7 @@ fun DeviceSettingsScreen( } } - val adaptiveNoise = device.adaptiveAudioNoise - if (features.hasAdaptiveAudioNoise && adaptiveNoise != null) { - item("adaptive_noise") { - AdaptiveNoiseSlider( - level = adaptiveNoise.level, - onLevelChange = onAdaptiveAudioNoiseChange, - enabled = enabled, - ) - } - } - - // Controls section + // ── Controls ───────────────────────────────── item("controls_header") { SettingsCategoryHeader(text = stringResource(R.string.device_settings_category_controls_label)) } @@ -383,23 +392,6 @@ fun DeviceSettingsScreen( } } - if (features.hasStemConfig) { - item("stem_actions") { - SettingsPreferenceItem( - icon = Icons.TwoTone.TouchApp, - title = stringResource(R.string.stem_actions_title), - subtitle = stringResource(R.string.stem_actions_nav_description), - onClick = onStemActionsClick, - enabled = enabled, - ) - } - } - - // Additional settings section - item("additional_header") { - SettingsCategoryHeader(text = stringResource(R.string.device_settings_category_additional_label)) - } - if (features.hasMicrophoneMode) { val micMode = device.microphoneMode ?: AapSetting.MicrophoneMode(AapSetting.MicrophoneMode.Mode.AUTO) @@ -420,6 +412,23 @@ fun DeviceSettingsScreen( } } + if (features.hasStemConfig) { + item("stem_actions") { + SettingsPreferenceItem( + icon = Icons.TwoTone.TouchApp, + title = stringResource(R.string.stem_actions_title), + subtitle = stringResource(R.string.stem_actions_nav_description), + onClick = onStemActionsClick, + enabled = enabled, + ) + } + } + + // ── General ────────────────────────────────── + item("general_header") { + SettingsCategoryHeader(text = stringResource(R.string.device_settings_category_general_label)) + } + if (features.hasEarDetectionToggle) { val earDetection = device.earDetectionEnabled ?: AapSetting.EarDetectionEnabled(enabled = true) @@ -467,7 +476,7 @@ fun DeviceSettingsScreen( } } - // Connections section + // ── Connections ─────────────────────────────── val connectedDevices = device.connectedDevices if (connectedDevices != null && connectedDevices.devices.isNotEmpty()) { item("connections_header") { @@ -481,7 +490,7 @@ fun DeviceSettingsScreen( } } - // EQ visualization (debug only — internal adaptive calibration data, not user-actionable) + // EQ visualization (debug only) val eqBands = device.eqBands if (eu.darken.capod.BuildConfig.DEBUG && eqBands != null && eqBands.sets.isNotEmpty()) { item("eq_header") { @@ -980,6 +989,7 @@ private fun NoiseControlCombined( onModeSelected: (AapSetting.AncMode.Value) -> Unit, cycleMask: Int?, onCycleMaskChange: (Int) -> Unit, + onAllowOffChange: (Boolean) -> Unit = {}, enabled: Boolean, ) { val displayMode = pendingMode ?: currentMode @@ -990,64 +1000,87 @@ private fun NoiseControlCombined( AapSetting.AncMode.Value.ADAPTIVE to 0x08, ) - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.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 + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp), + ) { + Column(modifier = Modifier.padding(vertical = 4.dp)) { + Text( + text = stringResource(R.string.device_settings_noise_control_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 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() - .clickable(enabled = enabled) { onModeSelected(mode) } - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // Visibility toggle for cycle (only if device supports listening mode cycle) - if (cycleMask != null) { - IconButton( - onClick = { - if (inCycle == true && canRemoveFromCycle) { - onCycleMaskChange(cycleMask xor bit) - } else if (inCycle != true) { - onCycleMaskChange((cycleMask ?: 0) or bit) - } - }, - enabled = enabled && (inCycle != true || canRemoveFromCycle), + 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 (inCycle == true && canRemoveFromCycle) { + onCycleMaskChange(cycleMask xor bit) + if (isOff) onAllowOffChange(false) + } else if (inCycle != true) { + onCycleMaskChange((cycleMask ?: 0) or bit) + if (isOff) onAllowOffChange(true) + } + }, + 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, ) { - Icon( - imageVector = if (inCycle == true) Icons.TwoTone.Visibility else Icons.TwoTone.VisibilityOff, - contentDescription = null, - tint = if (inCycle == true) { + Text( + text = mode.label(), + style = MaterialTheme.typography.bodyLarge, + color = if (isSelected) { MaterialTheme.colorScheme.primary } else { - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 1f else 0.5f) }, + fontWeight = if (isSelected) androidx.compose.ui.text.font.FontWeight.Bold else null, + modifier = Modifier.weight(1f), + ) + RadioButton( + selected = isSelected, + onClick = null, + enabled = enabled, + modifier = Modifier.padding(16.dp), ) } } - - // Mode label - 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) androidx.compose.ui.text.font.FontWeight.Bold else null, - modifier = Modifier.weight(1f), - ) - - // Selection indicator - RadioButton( - selected = isSelected, - onClick = { onModeSelected(mode) }, - enabled = enabled, - ) } } } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt index 1f73e365..59135394 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt @@ -149,6 +149,8 @@ internal class AapConnection( if (command is AapCommand.SetAncMode) { lastCommandedAncMode = command.mode + ancResendJob?.cancel() + ancResendJob = null val earDetection = currentState.setting() if (earDetection != null && !earDetection.isEitherPodInEar) { log(TAG) { "No pod in ear, queuing ANC mode: ${command.mode}" } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b276259a..aa0f479a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -465,7 +465,7 @@ Open device settings - Additional + General Microphone Which AirPod is used as the microphone Auto @@ -494,6 +494,7 @@ In a call Playing media + Noise Control Equalizer