ui(anc): Redesign the listening mode selector

Replaces the outlined segmented row with a filled track carrying a single
sliding thumb. The old control stacked three signals for one state (container
fill, bold, underline) in an outline treatment nothing else in the card uses,
which is what made it read as bolted on.

The track now sits at the same tonal step as the battery panel above it and
reuses the card's 16dp/12dp radii, so selection is carried by thumb position
and fill alone. Only the active mode is named and its slot expands to fit,
which keeps long translations off the width budget entirely.

Slot widths, label reveal and content tint are all derived from one clamped
fractional position, so geometry and content stay in lockstep. The thumb's
leading edge is that position in collapsed-slot units rather than a sum of
the animating widths, which would overshoot the target and crawl back.

Also fixes an invisible pending state: the in-flight branch set
activeContainerColor to secondaryContainer, which is exactly what
SegmentedButtonDefaults already used for the selected segment, so a requested
mode was indistinguishable from a confirmed one. An unconfirmed request now
draws a hollow pulsing thumb and tints the mode the pods are still in.

Transparency moves off Icons.TwoTone.Hearing, which is already the "In ear"
status chip and rendered identically two rows below it in the same card.
This commit is contained in:
darken
2026-08-18 15:04:50 +02:00
parent 412ea369f1
commit 8817da309f
10 changed files with 265 additions and 77 deletions
@@ -7,7 +7,7 @@ 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.material.icons.twotone.NoiseAware
import androidx.compose.ui.graphics.vector.ImageVector
import eu.darken.capod.R
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
@@ -25,7 +25,9 @@ fun AapSetting.AncMode.Value.shortLabel(context: Context): String = context.getS
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
// Not Hearing: that glyph is already the "In ear" status chip, so the two looked identical
// sitting in the same card. NoiseAware is the purpose-built ambient/transparency icon.
AapSetting.AncMode.Value.TRANSPARENCY -> Icons.TwoTone.NoiseAware
AapSetting.AncMode.Value.ADAPTIVE -> Icons.TwoTone.AutoAwesome
}
@@ -284,10 +284,10 @@ private fun ColumnScope.DualPodsCardExpanded(
// ANC mode selector
val ancMode = device.ancMode
if (device.isAapConnected && device.hasAncControl && ancMode != null) {
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(12.dp))
AncModeSelector(
currentMode = ancMode.current,
supportedModes = device.visibleAncModes,
supportedModes = device.visibleAncModes,
onModeSelected = { onAncModeChange?.invoke(it) },
pendingMode = device.pendingAncMode,
)
@@ -335,7 +335,7 @@ private fun ColumnScope.SinglePodsCardExpanded(
Spacer(modifier = Modifier.height(12.dp))
AncModeSelector(
currentMode = ancMode.current,
supportedModes = device.visibleAncModes,
supportedModes = device.visibleAncModes,
onModeSelected = { onAncModeChange?.invoke(it) },
pendingMode = device.pendingAncMode,
)
@@ -1,34 +1,74 @@
package eu.darken.capod.main.ui.overview.cards.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.RoundedCornerShape
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.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.layout.layout
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
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 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
import kotlin.math.abs
import kotlin.math.roundToInt
private val TrackShape = RoundedCornerShape(16.dp)
private val ThumbShape = RoundedCornerShape(12.dp)
private val TrackInset = 4.dp
private val TrackHeight = 48.dp
private val CollapsedSlotWidth = 52.dp
/**
* Listening-mode picker: one thumb travelling across a filled track, naming only the mode it sits
* on. Inactive modes stay icon-sized, which keeps the active label free of the width budget and
* therefore readable in every locale.
*/
@Composable
fun AncModeSelector(
modifier: Modifier = Modifier,
@@ -38,73 +78,212 @@ fun AncModeSelector(
pendingMode: AapSetting.AncMode.Value? = null,
enabled: Boolean = true,
) {
val displayMode = pendingMode ?: currentMode
Box(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),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
colors = if (pendingMode != null && mode == displayMode) {
SegmentedButtonDefaults.colors(
activeContainerColor = MaterialTheme.colorScheme.secondaryContainer,
activeContentColor = MaterialTheme.colorScheme.onSecondaryContainer,
)
} else {
SegmentedButtonDefaults.colors()
},
icon = {},
label = {
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(),
)
}
},
)
if (supportedModes.isEmpty()) return
val currentIndex = supportedModes.indexOf(currentMode)
// A requested mode can be missing from the list (e.g. one filtered out of the listening-mode
// cycle), in which case the thumb stays on the mode the pods are actually in.
val requestedIndex = supportedModes.indexOf(pendingMode ?: currentMode)
val targetIndex = if (requestedIndex >= 0) requestedIndex else currentIndex
val isPending = pendingMode != null && pendingMode != currentMode
// With neither mode on the list there is nothing to highlight: park the thumb where it was and
// fade it out rather than falsely marking the first slot as active.
val lastKnownIndex = remember { mutableIntStateOf(targetIndex.coerceAtLeast(0)) }
val thumbIndex = if (targetIndex >= 0) targetIndex else lastKnownIndex.intValue
SideEffect { if (targetIndex >= 0) lastKnownIndex.intValue = targetIndex }
val animatedIndex by animateFloatAsState(
targetValue = thumbIndex.toFloat(),
animationSpec = spring(dampingRatio = 0.72f, stiffness = Spring.StiffnessMediumLow),
label = "ancThumbPosition",
)
val thumbAlpha by animateFloatAsState(
targetValue = if (targetIndex >= 0) 1f else 0f,
animationSpec = tween(200),
label = "ancThumbAlpha",
)
// While a mode change is in flight the thumb stays hollow and breathes; it only fills in solid
// once the pods confirm the new mode.
val pendingFill = if (isPending) {
val pulse by rememberInfiniteTransition(label = "ancPending").animateFloat(
initialValue = 0.10f,
targetValue = 0.30f,
animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
label = "ancPendingPulse",
)
pulse
} else {
0f
}
val accent = MaterialTheme.colorScheme.primary
val activeContent = if (isPending) accent else MaterialTheme.colorScheme.onPrimary
val idleContent = MaterialTheme.colorScheme.onSurfaceVariant
Surface(
modifier = modifier
.fillMaxWidth()
.then(if (enabled) Modifier else Modifier.alpha(0.5f)),
shape = TrackShape,
// Same tonal step as the battery panel above it, so the two read as siblings and the thumb
// is the only thing in the card competing with the gauges for attention.
tonalElevation = 4.dp,
) {
BoxWithConstraints(
modifier = Modifier
.padding(TrackInset)
.height(TrackHeight),
) {
val slots = supportedModes.size
val equalWidth = maxWidth / slots.toFloat()
val collapsedWidth = minOf(CollapsedSlotWidth, equalWidth)
val expandedWidth = maxWidth - collapsedWidth * (slots - 1)
// The spring is underdamped and overshoots past the end slots; clamp before deriving any
// geometry, otherwise the reveals stop summing to 1 and the slots no longer fill the row.
val position = animatedIndex.coerceIn(0f, (slots - 1).toFloat())
// Distance to the (fractional) thumb position drives slot width, label reveal and tint
// alike, so geometry and content stay in lockstep for the whole travel.
val reveals = supportedModes.indices.map { (1f - abs(it - position)).coerceIn(0f, 1f) }
val widths = reveals.map { collapsedWidth + (expandedWidth - collapsedWidth) * it }
// Everything left of the thumb is collapsed by definition, so its leading edge is just
// the fractional position in collapsed-slot units. Summing the animating widths instead
// would overshoot the target and crawl back.
val thumbOffset = collapsedWidth * position
Box(
modifier = Modifier
.offset(x = thumbOffset)
.width(expandedWidth)
.fillMaxHeight()
.alpha(thumbAlpha)
.clip(ThumbShape)
.background(if (isPending) accent.copy(alpha = pendingFill) else accent)
.then(if (isPending) Modifier.border(1.5.dp, accent, ThumbShape) else Modifier),
)
Row(
modifier = Modifier
.fillMaxSize()
.selectableGroup(),
) {
supportedModes.forEachIndexed { index, mode ->
AncModeSlot(
mode = mode,
label = stringResource(mode.shortLabelRes()),
reveal = reveals[index],
width = widths[index],
contentColor = when {
// Mid-flight the still-active mode keeps an accent tint, so it stays
// readable what the pods are doing while the request is unconfirmed.
isPending && index == currentIndex -> accent
else -> lerp(idleContent, activeContent, reveals[index])
},
selected = index == targetIndex,
enabled = enabled,
onClick = { onModeSelected(mode) },
)
}
}
}
}
}
@Composable
private fun AncModeSlot(
mode: AapSetting.AncMode.Value,
label: String,
reveal: Float,
width: Dp,
contentColor: Color,
selected: Boolean,
enabled: Boolean,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.width(width)
.fillMaxHeight()
.clip(ThumbShape)
.selectable(
selected = selected,
enabled = enabled,
role = Role.RadioButton,
onClick = onClick,
)
// Named on the slot itself rather than on whichever child happens to be visible: the
// label only exists once it has revealed, so deriving the name from the children would
// leave the freshly selected mode unnamed for the length of the animation.
.semantics { contentDescription = label }
.clipToBounds(),
contentAlignment = Alignment.Center,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 2.dp),
) {
Icon(
imageVector = mode.icon(),
contentDescription = null,
tint = contentColor,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(6.dp * reveal))
RevealingLabel(text = label, reveal = reveal, color = contentColor)
}
}
}
/**
* Label that wipes in from the left as [reveal] grows: it is measured at its natural width but
* reports only a fraction of it, so the row around it grows continuously instead of jumping the
* moment the text appears.
*/
@Composable
private fun RevealingLabel(
text: String,
reveal: Float,
color: Color,
) {
if (reveal <= 0f) return
Box(modifier = Modifier.clipToBounds()) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = color,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
// The slot already carries the accessible name; the text is decoration on top of it.
modifier = Modifier
.clearAndSetSemantics { }
.alpha(reveal)
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints.copy(minWidth = 0))
layout((placeable.width * reveal).roundToInt(), placeable.height) {
// Relative, so RTL anchors the text at its start edge and wipes in from the
// right instead of exposing the tail of the word.
placeable.placeRelative(0, 0)
}
},
)
}
}
private val PreviewModes = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.TRANSPARENCY,
AapSetting.AncMode.Value.ADAPTIVE,
)
@Preview2
@Composable
private fun AncModeSelectorPreview() = PreviewWrapper {
AncModeSelector(
currentMode = AapSetting.AncMode.Value.ON,
supportedModes = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.TRANSPARENCY,
AapSetting.AncMode.Value.ADAPTIVE,
),
supportedModes = PreviewModes,
onModeSelected = {},
)
}
@@ -114,12 +293,19 @@ private fun AncModeSelectorPreview() = PreviewWrapper {
private fun AncModeSelectorPendingPreview() = PreviewWrapper {
AncModeSelector(
currentMode = AapSetting.AncMode.Value.ON,
supportedModes = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.TRANSPARENCY,
),
supportedModes = PreviewModes.dropLast(1),
onModeSelected = {},
pendingMode = AapSetting.AncMode.Value.TRANSPARENCY,
)
}
@Preview2
@Composable
private fun AncModeSelectorDisabledPreview() = PreviewWrapper {
AncModeSelector(
currentMode = AapSetting.AncMode.Value.ADAPTIVE,
supportedModes = PreviewModes,
onModeSelected = {},
enabled = false,
)
}